diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 8a9cc8760..687508232 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -78,4 +78,7 @@ jobs:
run: uv run python -m pytest src/ --doctest-modules --no-cov
- name: Run the unit and integration suites with coverage
- run: uv run python -m pytest -n auto --cov
+ run: uv run python -m pytest -n auto --cov --ignore=tests/benchmarks
+
+ - name: Run the benchmarks
+ run: uv run python -m pytest tests/benchmarks --no-cov
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 0f67e8714..ae40ddf33 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -73,6 +73,14 @@ repos:
pass_filenames: false
verbose: true
+ - id: shortcut-actions
+ name: shortcut actions
+ entry: uv run scripts/checks/shortcut_actions.py
+ language: system
+ files: (\.py|^src/sampletones_config/keybindings/.*\.yaml)$
+ pass_filenames: false
+ verbose: true
+
- id: mypy
name: mypy
entry: uv run mypy
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 5fc0c47df..b30d05826 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,75 +1,79 @@
-# SampleToNES
-
-## v0.3.2
-
-* Added NSF player and export.
-
-## v0.3.1 [2026-08-18]
-
-* Added support to [Bitphase](https://github.com/paator/bitphase).
-* Fixed arpeggio editing shifting a sample's pitch permanently.
-* Enhanced application options:
- * Display settings
- * Theme selector
- * Keybinding settings
-* Bumped the reconstruction data-version to `2.1`.
-* Improved Sequencer module playback.
-* Added song export to WAV/MP3.
-* Added tracker selection operations.
-* Added a _SampleToNES_ logo.
-
-## v0.3.0 [2026-07-31]
-
-* Added a _Sequencer_ view with FamiTracker-style patterns.
-* Added project export in a FamiTracker-compatible format.
-* Improved matching algorithms and extended available methods (`LogFFT`, `CQT`).
-* Improved the general layout of the application.
-* Changed the internal file formats (`.stn`, `.ins`).
-* Switched to `uv` as the package manager.
-* Detect the NVIDIA driver at setup and install the matching _CuPy_ build automatically, on Linux and Windows.
-
-## v0.2.3 [2026-01-09]
-
-* Improved the application's graphical interface.
-* Added the main page with filesystem explorer.
-* Added editing and saving reconstructions.
-* Added audio settings panel.
-* Implemented instructions library autogeneration.
-* Simplified instructions library tree.
-
-## v0.2.2 [2025-11-21]
-
-* Added GPU support via _CuPy_.
-* Fixed generation bugs.
-* Made minor visual improvements.
-* Improved code quality.
-* Released the application.
-
-## v0.2.1 [2025-11-17]
-
-* Optimized the output file structure.
-* Improved application error handling.
-* Enhanced task processing communication with the GUI.
-* Fixed installer bugs.
-* Created a Python package and an application installer via _PyInstaller_.
-
-## v0.2.0 [2025-11-08]
-
-* Created a graphical interface for the application.
-* Added application content:
- * Instruction instruction data explorer
- * Audio reconstruction viewer
-* Included instruction data creation and converter windows.
-* Added audio graphs and spectrum plots.
-* Implemented audio playback.
-
-## v0.1.0 [2025-10-23]
-
-* Added spectral features and FFT windows for the sample approximator.
-* Included mixer levels for adjusting the general amplitude of sound.
-* Created an instruction instruction data for reconstruction optimization.
-* Added instruction data and generation configurations.
-
-## v0.0.1 [2025-09-24]
-
-First version of _SampleToNES_, containing basic reconstruction scripts and all 2A03 generators.
+# SampleToNES
+
+## v0.3.2
+
+* Added NSF player and export.
+* Added stems conversion: mix several recordings into one reconstruction
+* Matched each stem against its own recording, so a stem plays what was recorded on it
+* Added a per-source channel cap
+* Bumped the reconstruction data-version to `2.2` with backward compatibility for `2.1`.
+
+## v0.3.1 [2026-08-18]
+
+* Added support to [Bitphase](https://github.com/paator/bitphase).
+* Fixed arpeggio editing shifting a sample's pitch permanently.
+* Enhanced application options:
+ * Display settings
+ * Theme selector
+ * Keybinding settings
+* Bumped the reconstruction data-version to `2.1`.
+* Improved Sequencer module playback.
+* Added song export to WAV/MP3.
+* Added tracker selection operations.
+* Added a _SampleToNES_ logo.
+
+## v0.3.0 [2026-07-31]
+
+* Added a _Sequencer_ view with FamiTracker-style patterns.
+* Added project export in a FamiTracker-compatible format.
+* Improved matching algorithms and extended available methods (`LogFFT`, `CQT`).
+* Improved the general layout of the application.
+* Changed the internal file formats (`.stn`, `.ins`).
+* Switched to `uv` as the package manager.
+* Detect the NVIDIA driver at setup and install the matching _CuPy_ build automatically, on Linux and Windows.
+
+## v0.2.3 [2026-01-09]
+
+* Improved the application's graphical interface.
+* Added the main page with filesystem explorer.
+* Added editing and saving reconstructions.
+* Added audio settings panel.
+* Implemented instructions library autogeneration.
+* Simplified instructions library tree.
+
+## v0.2.2 [2025-11-21]
+
+* Added GPU support via _CuPy_.
+* Fixed generation bugs.
+* Made minor visual improvements.
+* Improved code quality.
+* Released the application.
+
+## v0.2.1 [2025-11-17]
+
+* Optimized the output file structure.
+* Improved application error handling.
+* Enhanced task processing communication with the GUI.
+* Fixed installer bugs.
+* Created a Python package and an application installer via _PyInstaller_.
+
+## v0.2.0 [2025-11-08]
+
+* Created a graphical interface for the application.
+* Added application content:
+ * Instruction instruction data explorer
+ * Audio reconstruction viewer
+* Included instruction data creation and converter windows.
+* Added audio graphs and spectrum plots.
+* Implemented audio playback.
+
+## v0.1.0 [2025-10-23]
+
+* Added spectral features and FFT windows for the sample approximator.
+* Included mixer levels for adjusting the general amplitude of sound.
+* Created an instruction instruction data for reconstruction optimization.
+* Added instruction data and generation configurations.
+
+## v0.0.1 [2025-09-24]
+
+First version of _SampleToNES_, containing basic reconstruction scripts and all 2A03 generators.
diff --git a/Makefile b/Makefile
index 7ab6b93d5..18874d3e0 100644
--- a/Makefile
+++ b/Makefile
@@ -1,6 +1,6 @@
-.PHONY: help setup install build release system-deps run clean pre-commit test \
- ftm-samples icons check-import-boundary check-tag-names check-unused-tags \
- check-language-keys check-palette-colors calibration lint pylint mypy format
+.PHONY: help setup install build release system-deps run clean pre-commit test benchmarks \
+ ftm-samples nsf-samples nsf-render compression-report icons player check-import-boundary check-tag-names check-unused-tags \
+ check-language-keys check-palette-colors check-shortcut-actions calibration lint pylint mypy format
ifeq ($(OS),Windows_NT)
ifeq ($(MSYSTEM),)
@@ -66,11 +66,16 @@ help:
@echo $(Q) make setup - Set up development environment (uv); GPU auto-detected, GPU=0 forces CPU$(Q)
@echo $(Q) make pre-commit - Install pre-commit hooks$(Q)
@echo $(Q) make system-deps - Install system packages required to build and run (Debian-based, or Homebrew on macOS)$(Q)
- @echo $(Q) make build - Compile standalone executable (respects current deployment config)$(Q)
- @echo $(Q) make release - Compile standalone executable with the release deployment config$(Q)
+ @echo $(Q) make build - Compile standalone executable (development deployment config: DEBUG, strict history)$(Q)
+ @echo $(Q) make release - Compile standalone executable with the release deployment config (INFO, self-healing history)$(Q)
@echo $(Q) make test - Run unit tests with coverage$(Q)
+ @echo $(Q) make benchmarks - Run the measured-duration suite on its own$(Q)
@echo $(Q) make ftm-samples - Emit example .ftm files to build/ftm via the integration suite$(Q)
+ @echo $(Q) make nsf-samples - Emit example .nsf files to build/nsf via the integration suite$(Q)
+ @echo $(Q) make nsf-render - Render the .nsf files in build/nsf to waves with ffmpeg$(Q)
+ @echo $(Q) make compression-report - Measure the song codec into build/compression$(Q)
@echo $(Q) make icons - Generate the icon suite into src/sampletones_assets/icons$(Q)
+ @echo $(Q) make player - Assemble the NES player driver with cc65$(Q)
@echo $(Q) make calibration - Score the reconstruction corpus; the report lands in Documents/SampleToNES/calibration$(Q)
@echo $(Q) make clean - Remove build artifacts and cache files$(Q)
@echo $(Q) make lint - Run linting (pylint, mypy)$(Q)
@@ -107,13 +112,30 @@ pre-commit:
test:
$(call script,dev/tests)
+benchmarks:
+ uv run python -m pytest tests/benchmarks --no-cov
+
ftm-samples: export SAMPLETONES_FTM_OUTPUT_DIR := build/ftm
ftm-samples:
uv run python -m pytest tests/integration/famitracker
+nsf-samples: export SAMPLETONES_NSF_OUTPUT_DIR := build/nsf
+nsf-samples:
+ uv run python -m pytest tests/integration/nsf
+
+nsf-render: nsf-samples
+ uv run scripts/nsf_render.py
+
+compression-report: export SAMPLETONES_COMPRESSION_OUTPUT_DIR := build/compression
+compression-report:
+ uv run python -m pytest tests/integration/nsf/test_compression_report.py
+
icons:
uv run --group assets python scripts/assets/icons.py
+player:
+ uv run scripts/player.py
+
check-import-boundary:
uv run scripts/checks/import_boundary.py --all
@@ -129,6 +151,9 @@ check-language-keys:
check-palette-colors:
uv run scripts/checks/palette_colors.py
+check-shortcut-actions:
+ uv run scripts/checks/shortcut_actions.py
+
calibration:
uv run scripts/calibration.py
diff --git a/README.md b/README.md
index d80a99a01..170ba6561 100644
--- a/README.md
+++ b/README.md
@@ -33,7 +33,7 @@ It supports:
* `pulse2`
* `triangle`
* `noise`
-* exporting reconstructed audio as FamiTracker `.fti` instruments, Bitphase `.json` instrument presets, or `.wav`
+* exporting reconstructed audio as FamiTracker `.fti` instruments, Bitphase `.json` instrument presets, `.nsf` programs the NES itself plays, or `.wav`
## Installation
diff --git a/conftest.py b/conftest.py
index 1fee4a4cc..6b02405aa 100644
--- a/conftest.py
+++ b/conftest.py
@@ -1,25 +1,27 @@
-import importlib.util
from pathlib import Path
from typing import Final, Optional, Tuple
-JEEPNEY_MODULE: Final[str] = "jeepney"
+from sampletones_shared.utils.system.modules import JEEPNEY_MODULE, module_available
-PORTAL_PATHS: Final[Tuple[str, ...]] = (
+JEEPNEY_PATHS: Final[Tuple[str, ...]] = (
"src/sampletones_application/utils/file_dialogs/backends/portal",
+ "src/sampletones_shared/utils/system/reveal/file_manager1.py",
"tests/unit/sampletones_application/utils/file_dialogs/backends/portal",
"tests/unit/sampletones_application/utils/file_dialogs/test_selection.py",
+ "tests/unit/sampletones_shared/utils/system/reveal/test_file_manager1.py",
)
-PORTAL_LIBRARY_INSTALLED: Final[bool] = importlib.util.find_spec(JEEPNEY_MODULE) is not None
+JEEPNEY_INSTALLED: Final[bool] = module_available(JEEPNEY_MODULE)
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.
+ ``jeepney`` is declared for Linux alone, so what speaks D-Bus — the desktop portal's file
+ dialogs and the ``FileManager1`` reveal backend — is collected where that library is
+ installed. The behavior 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.
@@ -28,11 +30,11 @@ def pytest_ignore_collect(collection_path: Path) -> Optional[bool]:
Optional[bool]: ``True`` for a path that stays out of collection, ``None`` to leave the
choice with pytest.
"""
- if PORTAL_LIBRARY_INSTALLED:
+ if JEEPNEY_INSTALLED:
return None
root = Path(__file__).parent
- if any(collection_path.is_relative_to(root / path) for path in PORTAL_PATHS):
+ if any(collection_path.is_relative_to(root / path) for path in JEEPNEY_PATHS):
return True
return None
diff --git a/docs/api/index.md b/docs/api/index.md
index 3b457e75f..87a9b0cc5 100644
--- a/docs/api/index.md
+++ b/docs/api/index.md
@@ -13,7 +13,7 @@ from sampletones import (
InstructionLibrary,
Reconstruction,
Reconstructor,
- GeneratorName,
+ ChannelName,
Generator,
PulseGenerator,
TriangleGenerator,
@@ -31,8 +31,8 @@ from sampletones import (
| `Window` | analysis window derived from a config (`Window.from_config(config)`) |
| `InstructionLibrary` | the library of candidate instructions a reconstruction searches |
| `Reconstructor` | runs a reconstruction: `Reconstructor(config)("sample.wav")` |
-| `Reconstruction` | the result of a reconstruction — its approximation audio, per-generator instructions, and the config used |
-| `GeneratorName` | enum naming the four channels: `pulse1`, `pulse2`, `triangle`, `noise` |
+| `Reconstruction` | the result of a reconstruction — its approximation audio, per-channel instructions, and the config used |
+| `ChannelName` | enum naming the four channels: `pulse1`, `pulse2`, `triangle`, `noise` |
| `Generator` | shared base class of the oscillator generators |
| `PulseGenerator`, `TriangleGenerator`, `NoiseGenerator` | render one channel's waveform from an instruction |
| `Instruction` | shared base class of the per-frame channel instructions |
@@ -110,7 +110,7 @@ write_wave("reconstruction.wav", sample_rate, reconstruction.approximation)
### Load a reconstruction
-`Reconstruction.load` reads a saved `.stn` back into a `Reconstruction`, carrying its approximation, per-generator instructions, and config:
+`Reconstruction.load` reads a saved `.stn` back into a `Reconstruction`, carrying its approximation, per-channel instructions, and config:
```python
from sampletones import Reconstruction
@@ -120,7 +120,7 @@ reconstruction = Reconstruction.load("reconstruction.stn")
### Export instruments
-`Reconstruction.export` returns the per-channel [features](../formats/instruction-libraries.md), one entry per generator, and each set saves as a FamiTracker `.fti` instrument:
+`Reconstruction.export` returns the per-channel [features](../formats/instruction-libraries.md), one entry per channel, and each set saves as a FamiTracker `.fti` instrument:
```python
from sampletones import Reconstruction
diff --git a/docs/concepts/calibration.md b/docs/concepts/calibration.md
index a74453795..37d63ef4a 100644
--- a/docs/concepts/calibration.md
+++ b/docs/concepts/calibration.md
@@ -12,13 +12,13 @@ and runs as a script:
```
python scripts/calibration.py [--config ] [--methods fft,cqt]
[--perceptual-exponents 0.5,1.0] [--temporal-weights 0.1,0.3]
- [--generators pulse1,triangle,noise]
+ [--channels pulse1,triangle,noise]
```
The base configuration comes from `--config` when given; otherwise the saved
application configuration is used, so a run inherits the current app settings —
sample rate, gamma, selector and the rest. The channel set is the exception:
-calibration pins the generators itself (`--generators`, by default pulse 1 +
+calibration pins the channels itself (`--channels`, by default pulse 1 +
triangle + noise), so every run reconstructs with an explicitly chosen channel
set and results stay comparable across machines.
diff --git a/docs/concepts/compression.md b/docs/concepts/compression.md
new file mode 100644
index 000000000..9fab475a1
--- /dev/null
+++ b/docs/concepts/compression.md
@@ -0,0 +1,308 @@
+# Song compression
+
+This document explains how a whole song is squeezed into the space an NES program
+has for it. It is written to be readable without the source code, and it covers
+the ideas rather than the bytes: the layout the encoder writes is documented in
+[NSF export](../formats/nsf.md), and the package that implements it, along with
+the driver that reads it back, in [the console player](../development/player.md).
+
+The other exports _SampleToNES_ writes describe a song to a program that already
+knows how to play one. An `.nsf` carries its own player, so the song and the code
+that reads it share one 32 KB program area, and every byte the song takes is a byte
+the console has to hold in cartridge space. This is what makes compression part of
+the format rather than a convenience on top of it.
+
+The scheme is four layers, and each can be switched off on its own so that what it
+saves can be measured (§6). Nothing here is lossy: the values the console writes to
+its sound registers are exactly the values the sequencer plays.
+
+## 1. The problem
+
+A song reaches the console as **ticks** — the fixed-rate slices a reconstruction's
+envelopes advance through, the same slices the sequencer sounds a row in. On every
+tick each of the four channels has a full set of register values, and written out
+plainly that is 11 bytes a tick: three each for the two pulse channels and the
+triangle, two for the noise.
+
+At 60 ticks a second, 11 bytes a tick fills the space behind the driver in **49
+seconds**. A song of three minutes needs 118800 bytes and the console has about
+32000. So either songs stay under a minute, or the stream is stored in a form the
+driver can unpack as it plays.
+
+The content in those bytes is far smaller than the bytes themselves. What a tick
+actually says is a volume, a duty cycle, a pitch and a noise period — roughly four
+bytes' worth even before anything repeats. And a great deal repeats: a channel
+resting through a passage writes the same three bytes hundreds of times over, and a
+song built by playing the same drum sample at forty rows writes that sample's
+envelopes forty times.
+
+## 2. What a song looks like from the inside
+
+### 2.1 Planes
+
+The eleven bytes of a tick are stored channel by channel, so a channel's volume, its
+pitch low byte and its pitch high byte sit next to each other. That adjacency is
+exactly what hides the repetition: three unrelated series braided together turn over
+at every tick even when each of them is nearly constant.
+
+The first thing the encoder does is unbraid them. Each register becomes a **plane** —
+one byte per tick, the whole song long — and each plane is a series of its own: a
+volume envelope that falls and holds, a pitch line that steps between notes, a duty
+cycle that barely moves. An idle channel's planes become one value repeated, which
+costs almost nothing to state.
+
+This one change is most of the win. Split into planes and coded, the three-minute
+arrangement falls from 11 bytes a tick to about 1.7.
+
+### 2.2 Pitches instead of dividers
+
+A tone channel names a pitch by the **divider** the hardware counts down from, which
+takes two bytes and runs the opposite way to the note: higher notes have smaller
+dividers, and the steps between them are uneven. The encoder replaces the two
+divider planes with a **pitch index** — how far the note sits above the lowest
+pitch the table covers — and a **bend**, the divider steps the tick stands away from
+that note. The song block carries a table the driver resolves the index through and
+adds the bend to. A tone channel is therefore three planes, the same count as the
+registers it writes.
+
+Trading two dividers for an index and a bend is worth about a twentieth once the
+planes are coded, since a bend a song never uses is a plane of one value. The larger
+half is that **a pitch index can be transposed and a divider cannot.** The same figure played
+at five pitches is five unrelated byte sequences in divider space; in index space it
+is one sequence and five offsets. That is what turns a repeated sample into a single
+dictionary entry in §5.
+
+## 3. The token language
+
+Each plane is written as a sequence of **tokens**, and the driver reads them forward,
+one tick at a time. There are three things a token can say, and the opcode byte's top
+two bits say which:
+
+- **Hold** — keep the value the plane reached, for up to 64 ticks. One byte.
+- **Literal** — take the next few bytes, one per tick, up to 64 of them. One byte plus
+ the values.
+- **Phrase** — play entry *p* of the dictionary, for up to 256 ticks, optionally with
+ every value shifted. Two bytes, three if the phrase needs a full id byte or a shift,
+ four if it needs both.
+
+Literals alone can write any plane, so the codec always has an answer; holds and
+phrases are what make that answer short.
+
+Two properties of the encoding do most of the work later:
+
+**A token's count is a duration, not a length.** A phrase token says how many *ticks*
+it covers, and that may run past the phrase's last value — past the end, the plane
+holds that value onward. This is what a note does when its envelope has finished and
+the note is still sounding, and it means one dictionary entry serves the same figure
+however long it is held. A count shorter than the body cuts the note off, which is
+what a tracker does when the next note arrives early. So one entry covers every length
+a figure is played at.
+
+**A shift is added within the byte.** A transposed phrase carries one byte that is
+added to each of its values, wrapping at 256. On the 6502 that is a single addition,
+and a fall in pitch is simply the byte that wraps around to it. Together with the
+duration rule, one entry covers every pitch *and* every length a figure is played at.
+
+## 4. Reading a plane the cheapest way
+
+A plane usually admits many readings. A run of eight identical values can be one hold,
+or two holds, or a literal, or the tail of a phrase somebody else pays for. The
+readings cost different numbers of bytes, and the differences compound over a song of
+thousands of ticks.
+
+So the encoder does not pick a reading by rules of thumb; it searches for the cheapest
+one. The plane becomes a graph: each tick is a node, each token that could start there
+is an edge to the tick after the ones it covers, and the edge's weight is the bytes
+that token takes. **The cheapest path across the plane is its encoding** — and because
+the weights are bytes, the search optimizes the very quantity that has to fit in the
+program area.
+
+### 4.1 The edges
+
+From each tick, the encoder offers:
+
+- a **hold**, where the value repeats the one before it, running as far as the repeated
+ run does, capped at 64 ticks;
+- a **literal**, reaching this tick from the cheapest start within the last 64 ticks;
+- a **phrase**, one edge per dictionary entry the plane plays from here, covering as
+ many ticks as the plane agrees with it plus however long its last value carries.
+
+Literals need care, because every one of the 64 possible starts is a candidate and
+checking them all would make the parse quadratic. A literal costs its opcode and its
+bytes whatever its length, so a start that is beaten by a later one is beaten for good;
+keeping the live starts in that order leaves the best of them at the front, and the
+whole plane's literals are priced in one pass.
+
+### 4.2 Why the search beats taking the longest match
+
+The obvious alternative — at each tick take the longest phrase that matches, otherwise
+hold, otherwise spell out — is wrong in a way that shows up constantly. Taking a
+40-tick phrase for two bytes looks better than taking a 30-tick one, until it turns out
+that stopping at 30 would have let the next 200 ticks be a single hold. Costs also
+depend on the dictionary: the same phrase is two bytes with a cheap id and four with an
+escaped id and a shift. The search weighs those against each other; a rule of thumb
+cannot.
+
+### 4.3 Where a song comes round
+
+A song that repeats re-enters its streams partway through rather than at the beginning,
+and the driver arrives there with nothing behind it: it points each plane at a byte the
+header names and starts reading. For that to work, the loop tick has to **begin** a
+token on every plane, and that token has to state its values outright rather than lean
+on a value the plane reached earlier.
+
+The parse takes this as a constraint. The loop tick is a boundary: tokens may end there
+and start there, and none may span it. A hold is barred from starting there, since a
+hold is precisely a token that leans on what came before. Literals and phrases both
+state their own values, so either can open the loop.
+
+## 5. The dictionary
+
+The third token kind needs something to name. The dictionary is a table of **phrases** —
+runs of values a plane plays — stored once in the song block and named by tokens
+wherever they occur.
+
+A phrase's position in the table is its id, and the ids are not equally priced: the
+first 63 ride inside the opcode byte, and the rest need a byte of their own. So the
+order of the table is part of the encoding, and the phrases a song leans on hardest
+belong at the front.
+
+### 5.1 The instruments seed it
+
+A song is built by placing samples at rows, so the shapes its planes repeat are
+**knowable in advance rather than discoverable**. Each sample slice offers the two
+planes it writes, at the pitch and level it was reconstructed at, and every row playing
+that sample becomes a token naming those entries with the shift the row asks for. No
+search is involved, and this is where most of a project's compression comes from: on
+the three-minute arrangement the instruments alone take it from 1.6 bytes a tick to
+1.13, and transposition to 0.98.
+
+A project can offer more phrases than the table holds. When it does, each is weighed by
+what it would actually spare the song, and the ones that pay most keep their place; the
+export says so in the log rather than quietly dropping whichever sample happened to be
+listed last.
+
+### 5.2 The search fills the rest
+
+The instruments cover the notes, and leave behind everything else: rests, tails, the
+transitions between rows, and the whole of a reconstruction export, where each slice is
+played exactly once and nothing repeats by construction.
+
+The search works over that residue — the spans the current parse still spells out as
+literals. It gathers every run of 3 to 48 ticks that appears in them and groups them by
+**shape**: the step from each value to the next, so a figure played at five pitches
+collects into one candidate seen five times. Candidates occurring at least twice, in
+places that do not overlap, are scored:
+
+```
+gain = what the current parse pays for those spans today
+ − what tokens naming the phrase would pay instead
+ − the entry the phrase takes in the dictionary
+```
+
+Scoring against **the current parse** rather than against raw length is what keeps the
+search honest. A run of 200 identical values looks enormous by length and is worth
+nothing, because a hold already covers it for one byte. Only spans the parse is
+genuinely paying for can pay a candidate back.
+
+The best few candidates of each round are then confirmed the expensive way: the whole
+song is parsed again with each one added, and the round keeps whichever actually
+shrank the total. The estimate ranks; the re-parse decides. Rounds continue until a
+round earns nothing.
+
+### 5.3 Every entry pays for itself
+
+An entry costs its table slot, its length byte and its values, whether or not anything
+names it — so being used is not the same as being worth keeping. After the search, each
+phrase is weighed against a reading of the song in which no phrase exists at all, and
+the ones sparing fewer bytes than their entry takes are dropped.
+
+Dropping them changes which ids are cheap, which changes the parse, which changes what
+each phrase is worth. So the table is rebuilt with the busiest phrases first, the song
+is parsed again, and the process repeats until it settles — a few rounds at most.
+
+### 5.4 Matching is measured once
+
+The encoder parses the whole song many times: once as a baseline, once per confirmed
+candidate, once per settling round. A parse asks the same question at every tick — what
+does this phrase play here, and for how many ticks — and the answer depends only on the
+plane and the phrase. It cannot change between parses.
+
+So it is measured once per plane per phrase and kept for the whole encoding. A search
+round that adds one phrase measures that one phrase; everything already in the table
+answers from the reading taken when it arrived. This turns the cost of an encode from
+*parses × dictionary* into *dictionary*, and it is the largest reason a three-minute
+song encodes in about two seconds. Phrases are also offered only at the ticks whose
+first two steps match their own, so a reading covers the handful of places a phrase
+could begin rather than every tick of the song.
+
+## 6. What it achieves
+
+Measured over a three-minute arrangement, 10800 ticks, each layer added to the ones
+above it:
+
+| what is stored | bytes per tick | ratio | ticks that fit |
+|---|---|---|---|
+| a record per tick per channel | 11.000 | 1.00 | 2923 |
+| planes, coded | 1.735 | 6.34 | 18527 |
+| planes with a pitch index and a bend | 1.645 | 6.69 | 19644 |
+| phrases from the instruments | 1.191 | 9.23 | 27302 |
+| phrases played transposed | 1.046 | 10.52 | 31194 |
+| phrases from the search as well | **0.880** | **12.49** | **37238** |
+
+The whole song is 9509 bytes of the roughly 32000 available, and **37238 ticks is 10.3
+minutes at 60 Hz**, against the 49 seconds a record per tick reaches. Encoding it costs
+about two seconds; decoding it costs the console around twenty instructions per plane
+per tick, comfortably inside a video frame.
+
+`make compression-report` writes this table over a corpus of songs, and the format's
+constants are settled from it. Two of them were settled against expectation: splitting
+the duty cycle out of the control byte into a plane of its own **costs** 14 %, because
+volume and duty turn over together and a split pays two opcodes for what one covers;
+and the pitch index earns its place twice, 5 % directly and a further 12 % through the
+transposition it makes possible.
+
+## 7. Limitations
+
+- **The search struggles on dense reconstructions.** A reconstruction whose planes turn
+ over at nearly every tick offers enormous numbers of candidates, and past a cap the
+ search stops gathering and earns nothing for that song. It bites at lengths where the
+ song already exceeds the program area, so it costs a diagnosis rather than a song, but
+ a dense export of two minutes is stored materially larger than the same song at one.
+- **The dictionary holds 255 phrases.** A project of roughly 30 to 60 samples fills it
+ from its instruments alone, at which point the search has no room left to work in and
+ further samples compete for slots on measured value.
+- **A phrase holds at most 255 values and a token covers at most 256 ticks.** Longer
+ figures are stated as several tokens, which costs a couple of bytes each time.
+- **Compression is per plane.** Two channels playing the same figure at once share
+ dictionary entries, and nothing exploits the correlation between a channel's own
+ control and value planes.
+
+## Appendix — the shape of the format
+
+| quantity | value |
+|---|---|
+| planes | control and value for every channel, and a bend for each tone channel |
+| bytes per tick before coding | 11 |
+| ticks one hold covers | 1 to 64 |
+| values one literal carries | 1 to 64 |
+| ticks one phrase token covers | 1 to 256 |
+| phrase ids inside the opcode | 63 |
+| phrases in the dictionary | up to 255 |
+| values in a phrase | up to 255 |
+| candidate lengths the search gathers | 3 to 48 |
+| decoder state on the console | 88 bytes of zero page, 8 per plane |
+
+Where things live:
+
+| concern | module |
+|---|---|
+| planes, and the pitch index | `sampletones_player.compression.planes`, `.pitch` |
+| the token kinds and their byte costs | `sampletones_player.compression.tokens` |
+| the cheapest reading of a plane | `sampletones_player.compression.parse` |
+| the dictionary, its entries and its pruning | `sampletones_player.compression.dictionary` |
+| phrases the instruments offer | `sampletones_player.compression.seeds` |
+| phrases the search earns | `sampletones_player.compression.search` |
+| what a phrase plays against a plane | `sampletones_player.compression.matches` |
+| encoding, and the decoder the driver is held to | `sampletones_player.compression.encode`, `.decode` |
+| the opcode layout and its bounds | `sampletones_player.specification.compression` |
diff --git a/docs/concepts/instruction-library.md b/docs/concepts/instruction-library.md
index 65aba3baa..c0890acd3 100644
--- a/docs/concepts/instruction-library.md
+++ b/docs/concepts/instruction-library.md
@@ -1,19 +1,19 @@
# Instruction library
-An instruction library is the catalogue of NES sounds that _SampleToNES_
+An instruction library is the catalog of NES sounds that _SampleToNES_
searches when it reconstructs audio. It holds every instruction a channel can
play — each combination of pitch, volume, timbre and on/off state — together
with the waveform that instruction produces and a description of its frequency
-content. Reconstruction is then a matter of searching this catalogue: for each
+content. Reconstruction is then a matter of searching this catalog: for each
slice of the input, the engine looks for the library entries whose combined
sound is closest to that slice. [Reconstruction algorithms](reconstruction.md)
-describes that search; this page describes the catalogue it searches.
+describes that search; this page describes the catalog it searches.
## Why the library is precomputed
The number of distinct instructions is large but fixed — a few thousand per
channel — and the same candidates are compared against every frame of every
-sample. Rendering each candidate's waveform and analysing its spectrum once, up
+sample. Rendering each candidate's waveform and analyzing its spectrum once, up
front, turns the per-frame work into a lookup instead of a re-synthesis. A
library is therefore built once for a given configuration and reused across
every reconstruction that shares it.
diff --git a/docs/concepts/project.md b/docs/concepts/project.md
index 6f9ddaa75..3a403a66b 100644
--- a/docs/concepts/project.md
+++ b/docs/concepts/project.md
@@ -1,15 +1,15 @@
# Project
A project is a whole composition in _SampleToNES_: a song written for the four NES
-channels, together with the reconstructions it is built from. Where a
+channels, together with the voices it is built from. Where a
[reconstruction](reconstruction.md) is a single converted sound, a project holds
-many of them and the arrangement that plays them, so an entire piece lives as one
-file.
+many of them, the instruments written by hand beside them, and the arrangement that
+plays them all, so an entire piece lives as one file.
## What a project brings together
-- the **samples** — the reconstructions you have imported, each a playable
- instrument in the song;
+- the **voices** — the reconstructions you have imported and the instruments you have
+ written by hand, each of them something a row can play;
- the **song** — the arrangement itself: the patterns written for each channel and
the order they play in;
- the **timing and details** — the tempo, speed, and NES frequency the song plays
@@ -21,8 +21,9 @@ export the finished piece as a FamiTracker [module](../formats/famitracker.md).
## Self-contained and portable
A project embeds the reconstructions it uses rather than pointing at them elsewhere
-on disk, so moving or sharing the file carries the whole composition — the
-arrangement and every sound it needs. The embedded reconstructions are
+on disk, and writes each hand-written instrument into the document itself, so moving or sharing the
+file carries the whole composition — the arrangement and every sound it needs. The
+embedded reconstructions are
[detached](../formats/reconstructions.md#detached-reconstructions) from their
source-audio paths, which mean nothing on another machine, so the project opens the
same wherever it goes.
diff --git a/docs/concepts/reconstruction.md b/docs/concepts/reconstruction.md
index 05ebc6027..52945c0dc 100644
--- a/docs/concepts/reconstruction.md
+++ b/docs/concepts/reconstruction.md
@@ -30,12 +30,12 @@ models live in `sampletones_core.generators` and the instruction value types in
Reconstruction is a **search problem**. The input is cut into short, fixed-length
frames, and within each frame at most one instruction per channel is in effect. For
-every frame the system must pick, from a large but finite catalogue of NES
+every frame the system must pick, from a large but finite catalog of NES
waveforms, the combination of instructions whose mixed output best matches that
slice of audio. Two ingredients define the system:
- a **criterion** that scores how well a candidate matches the target (§4), and
-- a **selection strategy** that searches the catalogue efficiently (§5).
+- a **selection strategy** that searches the catalog efficiently (§5).
Everything is compared in a perceptually-weighted **frequency** representation
rather than raw samples, because two sounds that are perceptually identical can
@@ -47,7 +47,9 @@ have very different waveforms depending on phase.
input through a fixed sequence of stages:
1. **Load** the audio (`sampletones_core.audio`) — mix to mono, resample, and
- optionally clean it up (normalize, quantize).
+ optionally clean it up (normalize, quantize). Several sources load together, so
+ one scale drawn from the peak of their sum holds them at the balance they were
+ captured in.
2. **Set a working level** — scale the whole signal so its typical loudness sits in
the range the NES channels can reproduce, keeping quiet passages matchable (§3.4).
3. **Fragment** it into short, fixed-length frames
@@ -55,19 +57,30 @@ input through a fixed sequence of stages:
instruction per frame.
4. **Describe each frame** by a spectral feature that captures its frequency
content (§3).
-5. **Select** the instructions — for every frame and channel, pick the candidate
- that best matches, judged by the criterion (§5 and §4).
-6. **Render** the chosen instructions back into audio through the generators,
+5. **Assign** every frame's channels to the sources, and with each channel the
+ candidates it may sound there, each source judged against its own audio by the
+ criterion (§5 and §4).
+6. **Decode** each channel's stream, reading its candidates across the whole
+ recording (§5).
+7. **Refine** each chosen note onto the divider the recording's own fundamental stands at (§6).
+8. **Render** the chosen instructions back into audio through the generators,
keeping each oscillator continuous across frames.
-7. **Reassemble** the channels into the final approximation and package it, with the
+9. **Reassemble** the channels into the final approximation and package it, with the
instruction streams, as a `Reconstruction`.
-Stages 3–5 are where the algorithms described below live; the rest is preparation
+Stages 3–7 are where the algorithms described below live; the rest is preparation
and playback.
+A run says which of these it is in as it passes through them, so a reader watching a
+conversion sees it move rather than waiting for the file. `ReconstructionStage` gathers
+the eight steps into the four a reader is told apart — loading, matching, decoding,
+rendering — and states the share each holds of the whole run;
+[`progress.md`](../development/progress.md) describes how that account reaches the
+screen from the worker process it is made in.
+
## 3. Representing a frame
-### 3.1 The candidate catalogue (library)
+### 3.1 The candidate catalog (library)
Before any reconstruction, `sampletones_core.library` precomputes a **library**: for
every possible instruction it renders the waveform its generator produces and stores
@@ -111,7 +124,7 @@ sharper frequency resolution requires a longer time window, and vice versa):
milliseconds), so brief events are smeared in time at the low end. _SampleToNES_
computes the CQT **once over the whole signal** with a hop of one frame
(`calculate_cqt_spectrum_columns`), so each frame's energy is reported at its own
- time position and the per-frame columns line up with the FFT path's frame centres.
+ time position and the per-frame columns line up with the FFT path's frame centers.
The target and the library candidates are always described by the *same* method, so
their features are directly comparable bin by bin. All three methods share one scale
@@ -172,51 +185,163 @@ on machines with a GPU, runs on the array backend in `sampletones_shared`.
## 5. Choosing instructions
-Both selectors live in `sampletones_core.reconstructions.reconstructor.selector` and
-are chosen with `generation.decoder.selector`. They share the criterion and the
-library; they differ only in how they search.
+Two questions settle what a frame plays, and each has its own owner. **Ownership** —
+which channel a source holds this frame — is answered by the assignment in
+`sampletones_core.reconstructions.reconstructor.stems.assignment`. **The stream** —
+what a channel plays across the frames it holds — is answered by a decoder in
+`sampletones_core.reconstructions.reconstructor.decoder`, named by
+`generation.decoder.selector`. Both work from the same candidate scoring
+(`reconstructor/matching.py`), the same criterion and the same library.
+
+The assignment leaves every channel in play a **column** per frame: the candidates
+that channel may sound there, best first. The decoder reads those columns into one
+candidate per frame. Each decoder states how wide a column it reads, and the
+assignment builds columns to exactly that width.
-Both score candidates in two stages: every candidate is first ranked by the
+Candidates are scored in two stages: every candidate is first ranked by the
phase-independent spectral term, and the best `top_k` are then re-scored with the
full criterion, whose temporal term is evaluated on the candidate aligned to the
target (`find_best_phase`). The aligned phase stands in for the rendered phase,
which keeps each oscillator continuous across frames.
-### 5.1 Greedy (per-frame)
+### 5.1 Assigning channels
-The greedy selector treats every frame independently:
+A frame is assigned one pick at a time:
```
-remaining = {enabled channels}
-while remaining:
- pick the single (channel, instruction) with the lowest cost
- across all candidates of all remaining channels
- subtract its rendered contribution from the frame's residual
- assign it and remove that channel from `remaining`
+free = {channels the setup covers}
+residual[source] = that source's own frame, for every source that sounds in it
+while a source may still take a channel and free is non-empty:
+ pick the single (source, channel, instruction) covering the most of
+ residual[source], across every channel that source may still take
+ subtract its rendered contribution from residual[source]
+ assign it and remove that channel from `free`
```
-It assigns each channel exactly once per frame, always letting the channel that fits
-the residual best go first. It is simple and fast, but it has **no memory between
-frames**: nothing discourages the instruction streams from jumping around frame to
-frame, which can sound jittery even when each individual frame is well matched.
+Every pick lets whichever channel fits that source's residual best go first. Where
+several channels share one generator kind, the lowest free channel of that kind
+represents it during scoring, so successive picks over one kind land on the lowest free
+channel. A channel still free when the picks end **rests**: it holds its channel's null
+instruction for that frame, which is what keeps every channel's stream in step with
+the frames it describes.
+
+A source takes a channel in the frames its own audio reaches a level a channel can
+render, and stands aside in the rest, so a frame it is silent in leaves its channels
+resting.
+
+A classic single-file conversion is one source covering every enabled channel, so the
+one residual is the frame itself and the loop assigns every channel in each frame the
+source sounds in. Several sources, a precedence hierarchy and a per-source channel cap
+are the general case, described in [Stems reconstruction](stems.md); there each residual
+holds one source's own sound, which is what makes the channel a source wins carry that
+source's material.
-### 5.2 Viterbi (continuity-aware)
+### 5.2 Greedy decoding
-The Viterbi selector adds temporal continuity. For each frame it keeps the *top-k*
-lowest-cost candidates per channel, forming a lattice of states over time. It then
+The greedy decoder plays each frame's best candidate, reading one candidate per
+column. Each frame is then decided by its own cost alone, which is fast and
+straightforward, and the instruction streams follow each frame's match wherever it
+leads — audible as jitter even where every individual frame is well matched.
+
+### 5.3 Viterbi decoding
+
+The Viterbi decoder weighs a frame's candidates against the frames around them. It
+reads `top_k` candidates per column, forming a lattice of states over time, and
finds, per channel, the lowest-cost **path** through that lattice, where the path
cost combines:
- the per-frame **match cost** (the criterion, as an emission cost), and
-- a **transition cost** between consecutive frames that penalizes discontinuity —
- switching a channel on or off, and changing pitch, volume or timbre.
+- a **transition cost** between consecutive frames that grows with what changes
+ between two instructions — turning a channel on or off, and changing pitch, volume
+ or timbre.
Minimizing emission plus transition costs (the classic Viterbi dynamic program)
yields instruction streams that track the audio while changing only when the
-improvement in match quality outweighs the cost of the change. The result is
-smoother and more musical than the greedy output. It is the default.
-
-## 6. Rendering and reassembly
+improvement in match quality outweighs the cost of the change. The result is smoother
+and more musical than the greedy output. It is the default.
+
+A resting frame reaches the decoder as a column of one, so a channel that no source
+took sits in the path as the off state it is, and coming back on costs what any other
+on/off change costs.
+
+## 6. Refining the pitch
+
+The catalog is built on the equal-tempered grid, so the matching can place a frame no closer than
+the nearest semitone. The hardware is finer than that: a note reaches a channel as an 11-bit
+divider, and one step of that divider spans **0.85 cents at A-0, 4 cents at C-3, 16 cents at C-5**,
+reaching a whole semitone only around C-7, where the divider grid and the note grid meet. Everything
+below that is room the matching leaves unused, and material that was never in A=440 equal
+temperament — most recordings of most instruments — sits somewhere inside it.
+
+`sampletones_core.reconstructions.reconstructor.refinement` spends that room, after the decoder has
+settled which note each frame plays and before the frames are rendered.
+
+### 6.1 Reading rather than searching
+
+The refinement does not search. Two measurements settle why:
+
+- Against a **matched** candidate the criterion answers a detune smoothly and monotonically — a
+ 25-cent error costs about 0.09 where a 50-cent error costs about 0.40. Against a **realistic**
+ target, where the candidate cannot match the timbre, that response is a small ripple on a
+ timbre-dominated floor with many local minima, and taking the lowest-cost divider over a sweep
+ lands 15–30 cents from the truth.
+- Searching also costs what the library exists to avoid. Scoring one extra candidate per frame
+ means rendering it and extracting its feature, which measures around **2.1 s per second of
+ audio** — more than a whole conversion of the same audio.
+
+So the answer is read out of the transform instead. `sampletones_core.fft.instantaneous` takes the
+**phase** the constant-Q transform already computes and `calculate_cqt_spectrum_columns` discards.
+A partial standing between two bin centers still advances its phase at its own rate, so comparing
+that advance across two columns against the rate the bin itself turns at states the partial's
+frequency far more finely than the bins are spaced. Reading the first few harmonics of the note the
+decoder chose, each weighted by the energy behind it and each settled against the fundamental the
+harmonics below it agreed on, places the note **within a tenth of a cent** across the whole range.
+
+The reading also states how much of the frame stands behind it — the share of the column's energy
+its harmonics hold. A pitched frame reads around 0.5, a frame sharing the channel with another tone
+around 0.3, and noise around 0.04, so one threshold separates the frames worth bending from the
+frames with no pitch to read.
+
+### 6.2 Landing the note, and holding it
+
+A reading becomes a bend through the generator, which owns the divider geometry: `bend_towards`
+answers with the divider steps that land the note nearest the frequency read, bounded by
+`bend_range` — **half the gap to each neighboring note**. That bound is what leaves the refined
+pitches gapless: note *n* covers `[(tₙ + tₙ₊₁) / 2, (tₙ + tₙ₋₁) / 2]`, and those windows tile the
+divider range exactly, so every divider the notes span is reachable and none is claimed twice.
+
+A bend that followed every reading exactly would jitter, and jitter is more audible than the tuning
+it chases. So the per-frame proposals are settled by a change-penalised walk, the same shape the
+Viterbi decoder settles a note contour with: the cost of a bend is how far it stands from that
+frame's reading, plus a toll on changing at all. The states a frame may take are the bends its
+neighborhood proposed together with no bend, which keeps the walk to a handful of states even where
+a note owns tens of dividers.
+
+### 6.3 What it costs, and what it leaves alone
+
+The refinement enumerates no candidate, rescores nothing, and leaves the library, the per-frame
+matching and the decoder's lattice exactly as they were. What it adds is one transform per
+recording and a small walk per channel.
+
+What that transform costs depends on the machine, and the spread is wide: on a CUDA build it
+disappears into the noise, while on a CPU build it is a measurable share of a short conversion —
+a tenth or more, since the reading needs a handful of bins per frame and the transform computes
+every bin the spectrum covers. Restricting it to the bins the chosen notes actually name is the
+work `docs/development/bugs-and-todos.md` records under **Features**.
+
+A frame makes no proposal where it rests, where its channel is not pitched — the noise channel's
+sixteen periods have no finer grid — or where its reading falls below the confidence threshold. A
+conversion that bent no note records both bend dimensions as ones the channel governs, so it writes
+the same instrument it wrote before the feature existed.
+
+| parameter | default | notes |
+|---|---|---|
+| `generation.refinement.enabled` | on | acts only where the run renders the chosen instructions |
+| `generation.refinement.confidence` | 0.15 | the share of a frame's energy its harmonics must hold |
+| `generation.refinement.change_weight` | 2.0 | divider steps of reading error worth avoiding one change |
+| `generation.refinement.window` | 4 | the frames on either side whose readings a frame may settle on |
+
+## 7. Rendering and reassembly
Once instructions are chosen, each one is rendered back through its generator
(`sampletones_core.generators`), which carries oscillator phase across frames so
@@ -227,7 +352,7 @@ per-channel instruction streams (which can be exported to a tracker format via
`sampletones_core.exporters`). The coefficient from §3.4 is stored so the
reconstruction and the original can be shown and played on a common scale.
-## 7. Limitations
+## 8. Limitations
- **Dynamic range.** A single NES tonal channel spans roughly 25 dB from its
quietest to its loudest note, and the coefficient is one global scalar. Material
@@ -237,8 +362,13 @@ reconstruction and the original can be shown and played on a common scale.
- **CQT time resolution.** Because constant-Q analysis needs long windows at low
frequencies, low-pitched transients are inherently smeared in time under `cqt`;
`fft`/`logfft` localize time better at the cost of low-frequency resolution.
-- **Per-channel independence in Viterbi.** Channels are decoded independently after a
- shared residual is formed, which is fast but not jointly optimal across channels.
+- **Per-channel independence in Viterbi.** Channels are decoded independently once the
+ assignment has settled their columns, which is fast but not jointly optimal across
+ channels.
+- **Refinement needs a fundamental to read.** A frame carrying several pitches at once, or one
+ whose sound is unpitched, states no fundamental for its channel and keeps the note the matching
+ chose. The room a bend has also closes with pitch: a divider step is a whole semitone from around
+ C-7 up, so notes there sound where the grid puts them.
## Appendix — key parameters and where things live
@@ -253,6 +383,7 @@ noise):
| spectral / temporal weight | 0.8 / 0.2 | criterion blend |
| spectral distance | β-divergence | also `squared`, `absolute` |
| selector | Viterbi | `greedy` / `viterbi` |
+| pitch refinement | on | bends each note onto the divider the source sounds |
| normalize / quantize | on / off | input preprocessing |
Package map:
@@ -262,10 +393,11 @@ Package map:
| NES channel models | `sampletones_core.generators` |
| instruction value types | `sampletones_core.instructions` |
| windowing, spectra, features | `sampletones_core.fft` |
-| candidate catalogue | `sampletones_core.library` |
+| candidate catalog | `sampletones_core.library` |
| scoring | `sampletones_core.reconstructions.criterion` |
| selection + assembly | `sampletones_core.reconstructions.reconstructor` |
| audio I/O and level | `sampletones_core.audio` |
| tracker export | `sampletones_core.exporters` |
+| pitch refinement | `sampletones_core.reconstructions.reconstructor.refinement` |
| criterion calibration | `sampletones_core.calibration` |
| analytic waveform synthesis | `sampletones_synthesis` |
diff --git a/docs/concepts/stems.md b/docs/concepts/stems.md
new file mode 100644
index 000000000..e5e2fc86b
--- /dev/null
+++ b/docs/concepts/stems.md
@@ -0,0 +1,284 @@
+# Stems reconstruction
+
+This document explains how one reconstruction is assigned across several stems.
+Consult it when changing the stems assignment algorithm, its configuration, the
+per-stem record a reconstruction carries, or the way the application loads,
+names, reveals, and plays the recorded stems. The single-sample pipeline this
+builds on is described in [Reconstruction](reconstruction.md), and the stored
+record in [Reconstructions](../formats/reconstructions.md).
+
+A stems reconstruction converts several audio stems at once. Each stem is matched
+against the instruction library on its own; within each frame, the channels are
+handed to the stems one pick at a time, following a precedence hierarchy. The
+result is one reconstruction whose `stems_data` records, per channel and frame,
+which stem's stream plays.
+
+## Principles
+
+### 1. Every conversion is a stems conversion
+
+Below the conversion job there is one pipeline and one entry point. A job names
+the recordings it mixes, the stems setup that hands their channels out, and the
+file it writes; a conversion from a single file is the job whose setup holds one
+stem over every enabled channel. What the reader chose stays above that line:
+the application decides how many jobs a request makes and what setup each
+carries, and a batch is many single-source jobs rather than a mode of its own.
+
+This is what lets a channel cap, a hierarchy and a per-source channel set reach
+every conversion alike, and what keeps the classic run from being a second path
+that has to be kept in step.
+
+### 2. A stem is matched against its own recording
+
+Every stem is loaded, padded to the longest stem's length, and framed on its own,
+so a stem's picks are scored against the sound that stem contributes and the
+channels it wins carry that recording. Ownership and content then say the same
+thing: a stem heard on its own plays what was recorded on it.
+
+The mix keeps the two jobs it answers: the whole set is scaled by one factor drawn
+from the peak of its sum, which holds the stems at the balance they were captured
+in, and the working-level coefficient is measured on that mix, exactly as for a
+single file. The reconstruction the run assembles is the sum of the stems'
+approximations, which approximates the mix because each part approximates its part.
+
+### 3. A stem sounds where its recording sounds
+
+A stem takes a channel in the frames its own recording reaches a level a channel
+can render, and stands aside in the rest. A channel a passing stem leaves free goes
+to a stem that does sound there, or rests. This is what keeps a recording quiet
+through a passage from sounding that passage on the channels it holds elsewhere.
+
+### 4. One greedy pick at a time
+
+A pick scores each eligible stem's candidates against what is left of that stem's
+own frame, with the same two-stage criterion the single-sample pipeline uses
+(`FrameMatcher`), takes the winning offer across the active level, subtracts its
+approximation from that stem's residual, and consumes the channel. Picks continue
+until every stem channel is assigned, or caps and free channels are exhausted.
+Each stem carrying a residual of its own is what keeps its later picks from
+re-approximating what its earlier picks already cover, while leaving what the other
+stems sound out of it.
+
+### 5. A frame is answered whole
+
+Every channel the setup covers leaves a frame either picked or **resting**. A
+resting channel holds its channel's null instruction over a silent frame and
+records the resting stem id, so instruction streams, rendered approximations and
+the per-frame stem record all run parallel to the frames they describe: frame
+*i* of a channel is frame *i* of the recording. A channel that rests through
+every frame stands by instead, carrying no stream at all.
+
+This is what makes a channel cap and a hierarchy usable. Without it, a frame a
+cap left unclaimed would shorten that channel's streams and carry its later
+frames early, so what the channel plays would drift out of step with the
+recording it was matched against.
+
+### 6. Ownership and decoding compose
+
+The assignment answers *which stem owns which channel this frame*; the decoder
+answers *what that channel plays across frames*. Each pick leaves the channel it
+won a column of candidates, as wide as the configured decoder reads, and the
+decoder chooses one candidate per frame from those columns — greedily, or along
+the lowest-cost path through the whole lattice. A resting frame reaches the
+decoder as a column of one, so a channel a cap left free sits in the path as the
+off state it is. See [Reconstruction §5](reconstruction.md) for the decoders
+themselves.
+
+### 7. Precedence orders, mode alternates
+
+The hierarchy groups stem ids into levels that pick in the listed order. In
+`strict` mode a level exhausts its stems' channel caps before the next level
+picks; in `round_robin` mode the levels take turns, granting every level's stems
+one channel per round. Both modes let every stem hold at most `channel_cap`
+channels per frame.
+
+### 8. A level's channel goes to the stem with the most to render
+
+A cost is a fraction of its own recording's energy, so two stems' costs stand on
+different scales and comparing them alone would hand a channel to whichever
+recording is easiest to approximate. Within a level, an offer is therefore ranked
+by the energy its candidate covers — the cost weighted by the energy behind it — so
+the channel reaches the stem with the most sound waiting. Precedence between levels
+stays the hierarchy's, which is what a reader arranges the levels to say.
+
+### 9. Ties resolve deterministically
+
+Equal offers go to the stem earlier in level order. Channels of one kind resolve to
+the lowest free channel, so successive picks over one kind land on the lowest free
+channel and a rerun assigns the same way every time.
+
+### 10. The single-sample case stays exact
+
+One stem covering every enabled channel, with a cap at the channel count,
+reproduces the classic greedy reconstruction pick for pick. Property tests hold
+the assignment against an independent restatement of that reconstruction —
+identical choices, instructions, and approximations — so the one pipeline serves
+the single-sample case exactly as it stands.
+
+### 11. The working level follows the frame budget
+
+A frame reaches as loud as the channels that may sound in it, so the level the
+mix is scaled to is measured against the mixer weights of the loudest covered
+channels, as many of them as one frame holds:
+
+```
+budget = min(covered channels, stems x channel cap)
+```
+
+A capped run therefore targets a level its channels can actually render, and a
+setup whose budget covers every channel measures against the same total the
+single-sample pipeline always did.
+
+## Mechanics
+
+A request becomes jobs through `reconstructions.converter`: a `ConversionPlan`
+answers with the `ConversionJob`s it divides into, resolved against the
+configuration the run uses. `GroupConversion` mixes the recordings it is given
+into one job, and `DirectoryConversion` scans a folder into one single-source job
+per audio file. `ReconstructionConverter` runs those jobs across its worker pool
+and reports the reconstructions written.
+
+`StemsConfig` (`reconstructor/stems/configs/`) is the setup: the entries with
+their ids and channels, the precedence hierarchy and its mode, and the channel
+cap. It validates its own consistency — unique ids, a hierarchy naming every
+entry exactly once, a cap of at least one — so an inconsistent setup can be
+neither built nor stored, and it derives the views the run reads (`entries_by_id`,
+`covered_channels`, `frame_budget`).
+
+The assignment lives in `reconstructor/stems/assignment/`:
+
+- `assign_frame` takes this frame of every stem, keyed by stem id, validates the
+ setup against the run's channels, and answers the frame whole: the picks in the
+ order they were made, each with its candidate column, together with the channels
+ left resting;
+- `AssignmentSession` carries one frame's progress — each stem's residual, the free
+ channels, the per-stem counts, and the stems sounding in the frame — and runs the
+ hierarchy's mode. It ranks a level's offers by `StemOffer.bid`, the energy a
+ candidate covers, which the matcher measures through `reference_energy`;
+- `TrackAssignment` gathers the frames into what the rest of the run reads: the
+ lattice each channel offers the decoder, and the stem owning each of its
+ frames.
+
+`Reconstructor.reconstruct` loads the sources through `load_stems`, which brings
+them to one length and one scale, measures the working level on their mix, frames
+each of them, assigns every frame, releases the channels that rested throughout,
+decodes the remaining lattices, and folds the decoded streams into the state in
+frame order — the order each generator's oscillator phase is carried in.
+
+`STEM_ACTIVITY_FLOOR` is the level a stem's frame reaches to take a channel: the
+quietest note any channel renders, measured against the working level.
+
+The record stored in a reconstruction (`stems_data`) holds the stems setup the
+assignment was made under and, per channel, the stem id holding each frame,
+parallel to the instruction streams. Every reconstruction carries one.
+
+The stems setup is built per conversion from the sources and the reader's
+choices and travels with the job; it is part of the request rather than of the
+standard configuration. A source the reader left holding no channel takes no part:
+the recordings and the entries are derived in one pass, so such a source reaches
+neither, and the target stays what the covered channels can render. The assignment
+is greedy per frame: continuity of *who* owns a channel across frames, and playback
+that decides per frame on the recorded streams, are future work.
+
+## The recorded stems in the application
+
+A stems reconstruction records its stem paths under `audio_filepath` as a tuple,
+in entry order; the serialized form carries them in order. The application reads
+them through `source_paths`: empty once the reconstruction is detached from its
+origin, one path for a single source, the tuple for stems.
+
+Opening the document loads the recorded stems through `load_stems`, the same call
+the conversion loads them with, so each one carries the level it holds in the mix
+and a stem heard on its own sounds at that level. The mix of them is the original
+audio the source toggle and the waveform offer, computed fresh on every load. A
+recorded stem absent or unreadable on this machine follows the single-source rule:
+the whole original is unavailable, the approximation stands on its own, and the
+application names the first missing path in its dialog.
+
+The document's name follows the naming rules in
+`sampletones_core.reconstructions.naming`, applied to the recorded paths in
+order: a single source names the document after the file's stem, several stems
+sharing one directory name it after that directory, and paths sharing no
+directory fall back to the `.stn` filename.
+
+The reconstruction tab names every recorded path on the Stems card, one row per
+stem, each row carrying its own full-path tooltip and revealing its recording on
+a click. The Audio source panel keeps the reconstruction's own file and the
+choice between the two waveforms. Locating reveals every recorded path at once:
+a Linux file manager offering `org.freedesktop.FileManager1` opens one window
+with every stem selected, and every other environment opens one window per
+directory holding them.
+
+## The stems card
+
+The reconstruction tab's Stems card turns the recorded assignment into a
+listener the user can steer. It draws the same list the converter's card draws:
+each row carries one stem under the level it was picked on, named by its
+recording, with a leading master box and a colored box on every channel the
+stem holds frames on. A setup line above the rows names the assignment's
+hierarchy mode and channel cap, and a **Collapse levels** toggle draws every row
+in one table where the banding is in the way. Ticking a box admits that stem's
+frames on that channel to everything the tab plays and exports; unticking
+silences them.
+
+### Principles
+
+1. **Selection filters what plays.** A ticked set projects the document rather
+ than mutating it: the waveform shows the ticked frames alone, the
+ reconstruction toggle plays them mixed, original playback plays the
+ recordings heard anywhere mixed, and WAV export writes the same filtered
+ projection. Each answer derives from the recorded per-channel assignment, so
+ a stem heard on one channel keeps its samples there and stays quiet on the
+ next.
+2. **A box stands where the choice reaches something.** A stem draws a box on a
+ channel exactly where the picker gave it a frame there, so every box the card
+ offers changes what is heard. A stem the picker never chose offers none, and
+ its row reads as holding no frames.
+3. **Every stem starts heard everywhere it holds frames.** A freshly opened
+ stems reconstruction ticks every box, which answers the full waveform and the
+ full original — the unfiltered document.
+4. **The global channel choice takes precedence.** A channel switched off for
+ the whole reconstruction mutes its column while leaving every value where the
+ reader put it, so switching the channel back on restores the per-stem choice
+ intact. The two compose by construction: the global choice filters the
+ partials, the stems choice the approximations.
+5. **The selection follows the open document.** The card lives with the
+ reconstruction it describes: opening a document seeds the rows and the ticked
+ boxes, a regenerated reconstruction keeps what the reader chose and ticks the
+ channels a stem newly reaches, and closing the document empties the card.
+6. **Listening choices stay out of the document.** The ticked set is session
+ state, like every choice that shapes what is heard — see
+ [Playback](../development/playback.md). Saving the reconstruction records
+ the assignment, never the selection. So is the banding: collapsing the levels
+ changes how the card draws, never what it describes.
+7. **Removing a recording edits the document.** Where a box steers listening,
+ the remove button rewrites what is described: the entry leaves the recorded
+ setup, its frames rest, and the change is asked about first and recorded in
+ the project history. A reconstruction holds at least one recording, so the
+ last row standing keeps its button held back.
+
+### Mechanics
+
+`ReconstructionData.partials_for` and `ReconstructionData.waveform_data` take a
+`StemSelection` — the stems each channel keeps — and zero the unselected frames
+per channel before mixing (`filter_approximations` in
+`sampletones_core.reconstructions.reconstruction.stems`), keeping every array at
+its unfiltered length, so a filtered mix aligns with the unfiltered one sample
+for sample. `original_mix_for` mixes the recordings of the stems heard on any
+channel. `ReconstructionPanelLogic` holds the channels each stem is heard on and
+re-answers the stems view model, the waveform, and the audio data whenever the
+choice changes; the coordinator wires the card's `on_stem_channels_changed` hook
+to that handler. A reconstruction that records one source presents a single row
+for its recording, and one that records no source shows the card's empty
+state.
+
+Removal runs through `without_stem`
+(`sampletones_core.reconstructions.reconstruction.stems.removal`), which returns
+a fresh reconstruction: the entry leaves the setup, taking its level along once
+that level holds nothing else; its source path leaves `audio_filepath` from the
+position it stood at; every frame it held states the silent instruction, zeroes
+its samples and takes `RESTING_STEM_ID`; a channel the removal empties stands
+by; and the mixed approximation is summed afresh. The tab coordinator hands the
+result on as a `ReconstructionEdit`, the payload both a regenerated instrument
+and a removed recording travel as, so one path rebinds the open document and
+records the edit against the project history.
diff --git a/docs/development/architecture.md b/docs/development/architecture.md
index fceecb7c5..39fca4369 100644
--- a/docs/development/architecture.md
+++ b/docs/development/architecture.md
@@ -1,8 +1,8 @@
# Application Architecture
-This document describes the design of `sampletones_application` — the GUI front-end of _SampleToNES_. It is prescriptive: it states the contracts each layer must honour, in the form they are enforced, and the rationale behind them. Use it as the reference when deciding where new code belongs.
+This document describes the design of `sampletones_application` — the GUI front-end of _SampleToNES_. It is prescriptive: it states the contracts each layer must honor, in the form they are enforced, and the rationale behind them. Use it as the reference when deciding where new code belongs.
-Concrete classes and modules appear throughout as **examples** that anchor a rule; the rules bind every instance, named or not. Known deviations from these contracts are tracked in `docs/development/bugs-and-todos.md`. Coding-level rules live in `docs/development/guidelines.md`; the undo subsystem has its own design document, `docs/development/undo.md`, the audio transport has `docs/development/playback.md`, the reconstruction browser has `docs/development/browser.md`, and the YAML configuration package has `docs/development/config-organization.md`.
+Concrete classes and modules appear throughout as **examples** that anchor a rule; the rules bind every instance, named or not. Known deviations from these contracts are tracked in `docs/development/bugs-and-todos.md`. Coding-level rules live in `docs/development/guidelines.md`; the undo subsystem has its own design document, `docs/development/undo.md`, the audio transport has `docs/development/playback.md`, the reconstruction browser has `docs/development/browser.md`, the YAML configuration package has `docs/development/config-organization.md`, how a long operation says how far it has come has `docs/development/progress.md`, and the packages the repository divides into have `docs/development/packages.md`.
---
@@ -69,6 +69,8 @@ This decouples widget construction (which happens during `create_panel()`) from
Services execute long-running work on background threads. Their results are posted to `CallbackQueue` with a priority, and the main-thread render loop drains the due results each frame within a per-frame time budget (`scheduling.queue_budget_seconds`), so a large backlog spreads across frames while rendering continues. Draining on the render thread keeps every callback's DPG work on the thread that owns the context. This is the only mechanism for crossing the thread boundary; applying a background result to UI state directly from the worker thread is forbidden.
+**The drain runs between frames, so a callback waits for none.** The render thread is inside the drain rather than inside a frame, which makes the next frame the drain's own to reach: `dpg.split_frame` there waits for what the wait itself prevents, and the application stops for good. Work that needs a drawn frame — reading a laid-out size, letting a configuration take effect — is scheduled through `FrameCallbackManager` and picked up when that frame arrives.
+
### 7. Construction flows from the composition root
`Application.__init__` constructs the application graph — managers, controllers, shared services, coordinators, the shell — and wires their callbacks. A tab coordinator in turn constructs the panels, logic objects, and tab-scoped services it owns. Beyond these two sites, no component constructs another major component: every dependency arrives as a constructor argument, and none is obtained through a global lookup.
@@ -97,7 +99,7 @@ language_manager[
### 9. `tags/` holds only DPG identifiers
-The `tags/` package contains only DPG widget string identifiers: `TAG_*` whole tags, and `SUF_*`/`PRE_*` fragments that compose into them. Dimensions, colours, timings, and display strings live in YAML configuration loaded at startup (`layout/`).
+The `tags/` package contains only DPG widget string identifiers: `TAG_*` whole tags, and `SUF_*`/`PRE_*` fragments that compose into them. Dimensions, colors, timings, and display strings live in YAML configuration loaded at startup (`layout/`).
**`compose_tag` is the one composer.** `tags/compose.py` owns `TAG_SEPARATOR` and the joiner; every tag reaches its final spelling through it. Each part is lowercased and its whitespace runs become single underscores, so a tag built from a runtime name — a sample title, a layer label — reads the same however that name arrives cased or spaced, and a part already holding a composed tag contributes its own segments, which is how a child tag extends its parent. Fragments hold bare segments (`SUF_GRAPH_PLOT = "plot"`) and gain separators only from the joiner, so a fragment reads as the segment it names and either end composes onto it.
@@ -124,7 +126,7 @@ A new exclusive operation joins by contributing its `is_active` to the authority
### 11. Platform and external-tool differences hide behind a backend Protocol
-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.
+Where behavior 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 (`locate_program`) 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 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.
@@ -136,7 +138,7 @@ DearPyGui gives every key handler the same global reach and no way for one to st
Each keyboard consumer registers one scope through `register(handle, *, priority, active)`, where `active()` reports whether the scope wants keys at this moment and `handle(event) -> bool` acts on the press and reports whether it claimed it. Three priorities order the whole application:
-| Priority | Scope | Active when | Behaviour |
+| Priority | Scope | Active when | Behavior |
|----------|-------|-------------|-----------|
| `MODAL` (100) | the open dialog's navigator | a modal dialog holds the keyboard | routes Tab/Enter/Escape to the dialog's focus ring and claims every press, so a dialog owns the keyboard exclusively while it is shown |
| `PANEL` (60) | a sequencer sub-panel (grid / order / samples) | its tab is in front and that sub-panel holds the cursor or selection | handles its tracker keys and yields the combinations it does not own so a higher-reaching shortcut still wins |
@@ -160,17 +162,36 @@ A preference layers over the shipped scheme. `ShortcutsConfig` holds the scheme
**A scheme is edited through a draft.** `ShortcutDraft` (`utils/gui/shortcuts/draft.py`) holds the scheme being edited together with the actions the reader has touched — the combination each was given, or nothing where it was left unbound — so what reaches the preference is those actions alone while every other key follows the scheme beneath. An assignment displaces: giving an action a combination its category already answers takes the key from the holder in the same step, which is what makes every scheme a draft produces a valid one, and the dialog names the holder and asks before that step is taken. The draft is what the dialog edits, and a commit is what activates it, so a reader rebinding Escape, Tab or Enter keeps the keys the dialog is operated by until they are done.
-**A scheme belongs to a platform; an action does not.** `ShortcutId` and `ShortcutCategory` are the same on every platform, and `PLATFORM_SCHEME_NAMES` (`constants/keybindings.py`) states which scheme each one ships — the choice a profile makes once, at creation, after which the stored name selects. The modifier table reads every spelling on every platform while `Modifier.SUPER` displays as the name the machine is labelled with, so a scheme written for one keyboard loads, validates and reads on another, and the completeness validation holds every shipped scheme to the same action set.
+**A scheme belongs to a platform; an action does not.** `ShortcutId` and `ShortcutCategory` are the same on every platform, and `PLATFORM_SCHEME_NAMES` (`constants/keybindings.py`) states which scheme each one ships — the choice a profile makes once, at creation, after which the stored name selects. The modifier table reads every spelling on every platform while `Modifier.SUPER` displays as the name the machine is labeled with, so a scheme written for one keyboard loads, validates and reads on another, and the completeness validation holds every shipped scheme to the same action set.
The router is constructed at the composition root and injected into every consumer (principle 7); its one global handler is bound in `shell.py` once the DPG context exists.
-### 13. A colour is a token, resolved where it is drawn
+### 13. A color is a token, resolved where it is drawn
-A colour is written as a palette token and stays one until it reaches DearPyGui. `BaseColor` (`utils/palette/colors/`) carries what was written, and its `rgba` property answers with the palette active at the moment of the read, so whoever holds the colour follows a palette swap. Every annotation names `BaseColor` — a dataclass field, a signature, a dictionary key — and `WrittenColor` appears only on the Pydantic field that validates a YAML entry. The read happens where the value is handed to a widget, and what a consumer keeps is the token.
+A color is written as a palette token and stays one until it reaches DearPyGui. `BaseColor` (`utils/palette/colors/`) carries what was written, and its `rgba` property answers with the palette active at the moment of the read, so whoever holds the color follows a palette swap. Every annotation names `BaseColor` — a dataclass field, a signature, a dictionary key — and `WrittenColor` appears only on the Pydantic field that validates a YAML entry. The read happens where the value is handed to a widget, and what a consumer keeps is the token.
A shade is composed by naming its form. `utils/palette/colors/` is a flat star: `base.py` declares the abstract `rgba`, and each form is a peer module beside it (`literal`, `named`, `faded`, `grayscale`, `blended`, `layered`), answering with a `BaseColor` of its own — `FadedColor(color=GrayscaleColor(color=token), fraction=0.3)`. Every form is a module-level frozen dataclass, so two identical compositions are one value and a theme cache keyed on a shade hits.
-What DearPyGui has already taken a copy of is registered rather than remembered by whoever set it. `PaletteBindings` (`utils/gui/palette/`) records each `(item, argument)` a palette colour reached, and `dpg_set_palette_color` / `dpg_add_palette_theme_color` are how a colour gets there. A palette change is then one switch: `PaletteSource.activate` fires the composition root's listener, which re-applies the bindings, refreshes the viewport clear colour, and repaints the sequencer for the row and cell highlights DearPyGui holds as table state. The `palette-colors` hook holds all three rules (see Enforcement).
+What DearPyGui has already taken a copy of is registered rather than remembered by whoever set it. `PaletteBindings` (`utils/gui/palette/`) records each `(item, argument)` a palette color reached, and `dpg_set_palette_color` / `dpg_add_palette_theme_color` are how a color gets there. A palette change is then one switch: `PaletteSource.activate` fires the composition root's listener, which re-applies the bindings, refreshes the viewport clear color, and repaints the sequencer for the row and cell highlights DearPyGui holds as table state. The `palette-colors` hook holds all three rules (see Enforcement).
+
+### 14. An action is declared once; whoever shows it prints it
+
+An **action** is one `ShortcutId` — the name a key press, a menu item and a context item all reach one behavior by. Declaring one is a chain of four links, and the `shortcut-actions` check holds every one of them (see Enforcement):
+
+| Link | Where | What it states |
+|------|-------|----------------|
+| The action | `utils/gui/shortcuts/ids.py` | its name, and the category that answers it |
+| Its keys | every scheme under `sampletones_config/keybindings/` | the combination that fires it, `~` where it ships unbound |
+| Its call | `shell.py` — a `ShortcutBindings` field and the entry naming it in the binding map, or membership of `FAMILY_SHORTCUT_IDS` | the one call the action makes |
+| Its label | a `KeybindingActionElements` member and its `en.yaml` entry | how the keybindings editor lists it |
+
+Two kinds of action state their call differently, and the check knows both. One that a whole enum parameterises — an export item per format, an item per channel — is a **family**: a `Dict[Enum, ShortcutId]` in `ids.py` whose reader dispatches on the enum member. A family is *declared*, not recognized: `FAMILY_SHORTCUT_IDS` names the mappings that are ones, so what excuses an action from stating a call of its own is written down rather than inferred from the shape of a dictionary — `SHORTCUT_IDS_BY_NAME` answers with every action and is deliberately not among them. A **panel-scope** action states no call at all, because its key scope (principle 12) acts on the press itself. A `DIALOG` action is named nowhere in the editor, since a dialog is operated by the keys its category holds.
+
+**A menu item is a view of an action, never a second declaration of it.** `ShortcutManager.add_menu_item(shortcut_id, ...)` is how a menu names one: it takes both the accelerator and the call from the action, and keeps the item under it, so a rebind re-prints the key already on screen. An item passes a `callback` of its own only where it carries a state to show, and then that call is the one switching the state it shows.
+
+**A set of actions several menus show is declared by whoever owns them, once.** The owner states one builder — `GUISequencerVoicesPanel.add_action_items` for a voice, a grid's edit surface for a cell — and each door decides where to print it: the panel's own row menu, the menu bar's **Edit** group through `EditSurfaceProtocol` and `EditRouter`, the **Voice** group through the panel. Adding an action to the builder reaches every door, and the dividers around it belong to the door rather than to the set.
+
+**A menu whose contents follow a selection states them when it is opened.** A menu bar is built once, while what an item should say follows the cursor at the moment a reader opens the menu. `ui/elements/menu_section.py::MenuSection` is that mechanism: a marker leads the menu, the framework reports it drawn once a frame while the menu stands open, and a gap in those reports marks a fresh opening and restates the section. The marker leads rather than trails because a container standing below a menu item takes the width those items span as its own, which the popup would then grow to fit on every frame.
---
@@ -178,18 +199,19 @@ What DearPyGui has already taken a copy of is registered rather than remembered
Two mechanisms keep the codebase aligned with this document.
-**Import-expressible contracts are enforced by script.** `scripts/checks/import_boundary.py` (a pre-commit hook, also run via `make check-import-boundary`) encodes one rule per layer, mirroring the **Must not import** lists in the Layer Reference; the Layer Reference is the source of truth, and a divergence between it and the script is itself a defect. Where a layer may consume another layer's data contract while its implementation stays out of reach (logic and the service result types), the rule carries an explicit contract exemption. The hook audits the entire source tree on every commit (`--all`), so strengthening a rule surfaces violations in files a commit never touched. That property sets the working idiom for structural refactors: turn the stricter rule on first, and let the failing hook enumerate the remaining work.
+**Import-expressible contracts are enforced by a check.** `sampletones_config/boundaries/rules.yaml` states one rule per layer, mirroring the **Must not import** lists in the Layer Reference; the Layer Reference is the source of truth, and a divergence between it and the configuration is itself a defect. The same domain holds the order the repository's packages import each other in, and the layering inside `sampletones_player`, both declared as layer tables in `docs/development/packages.md`. `sampletones_config/boundaries/` declares what the boundaries are, `sampletones_shared/meta/import_boundary/` holds how they are read and reported, and `scripts/checks/import_boundary.py` (a pre-commit hook, also run via `make check-import-boundary`) runs them over the source tree. A rule names the prefixes it reaches through the groups `boundaries/general.yaml` declares, so the interface several layers stay clear of is written once and each rule names it. Where a layer may consume another layer's data contract while its implementation stays out of reach (logic and the service result types), the rule names the contracts group that stays in reach. The hook audits the entire source tree on every commit (`--all`), so strengthening a rule surfaces violations in files a commit never touched. That property sets the working idiom for structural refactors: turn the stricter rule on first, and let the failing hook enumerate the remaining work.
-**The identifier vocabularies are enforced the same way.** Further scripts under `scripts/checks/` run whole-tree as pre-commit hooks, each also available as a `make check-*` target:
+**The identifier vocabularies, and the declarations that complete them, are enforced the same way.** Further scripts under `scripts/checks/` run whole-tree as pre-commit hooks, each also available as a `make check-*` target:
| Hook | Script | What it holds |
|------|--------|---------------|
| `language-keys` | `language_keys.py` | Code and `en.yaml` against each other, in both directions: a literal key names an entry, every entry is reached by some lookup, and a lookup states values the check can read (principle 8) |
| `tag-names` | `tag_names.py` | A tag constant's name against the tag it composes (principle 9) |
| `unused-tags` | `unused_tags.py` | Every `TAG_*`/`SUF_*`/`PRE_*` the `tags/` package declares against the reads of it across `src/`, `tests/`, and `scripts/`, where an import alone stands at no reads |
-| `palette-colors` | `palette_colors.py` | A colour as a token up to the moment it is drawn with: an attribute assigned a resolved `rgba`, a theme colour filled outside the palette bindings, and a hex literal in the shipped configuration outside `palettes/` (principle 13) |
+| `palette-colors` | `palette_colors.py` | A color as a token up to the moment it is drawn with: an attribute assigned a resolved `rgba`, a theme color filled outside the palette bindings, and a hex literal in the shipped configuration outside `palettes/` (principle 13) |
+| `shortcut-actions` | `shortcut_actions.py` | Every action against the links it needs: a combination in every shipped scheme, a name the keybindings editor lists it by, and — for an application-scope action — the call it makes, whether its own binding or a family (principle 14) |
-They read the source as an AST through the shared layer in `sampletones_shared/meta/source/`, which discovers modules, resolves the receiver a subscript sits on, and expands an enum-annotated key part to its members; the palette check reads the shipped YAML beside it. That layer derives each package directory from its own location and reports a root it finds nothing at, so a check that sweeps nothing fails loudly where it would otherwise pass clean. Because the checks are global by nature — a dead entry and an unread fragment are both absences — the hooks pass whole-tree rather than filenames.
+They read the source as an AST through the shared layer in `sampletones_shared/meta/source/`, which discovers modules, resolves the receiver a subscript sits on, and expands an enum-annotated key part to its members; the palette and shortcut checks read the shipped YAML beside it. That layer derives each package directory from its own location and reports a root it finds nothing at, so a check that sweeps nothing fails loudly where it would otherwise pass clean. Because the checks are global by nature — a dead entry and an unread fragment are both absences — the hooks pass whole-tree rather than filenames.
**Behavioral contracts are enforced by review.** Contracts a grep cannot see — where state lives, which methods touch DPG, how errors travel — are upheld in code review against this document. Deviations that survive review are recorded in `docs/development/bugs-and-todos.md § Architecture` until they are paid off; the ledger, not the codebase, is the memory of what is currently out of line.
@@ -205,7 +227,7 @@ They read the source as an AST through the shared layer in `sampletones_shared/m
- A panel creates its entire widget tree in one call to `create_panel(parent)`, rooting its subtree at `self.tag` inside the coordinator-injected `parent`, and calls DPG afterwards only in `update_view()`, `update_*` methods, and event callbacks wired by DPG itself.
- Panels hold only visual state: their tag, their child widget references, and layout dimensions. Domain objects stay in logic; panels receive projections of them.
- A panel never encodes its own placement: it does not compose a column tag (`SUF_PANEL_*`) as its parent, and it never hosts a sibling panel. Tab layout is the coordinator's (see the Coordinators reference). Where a section is a card, one card is one panel is one module; the coordinator declares which cards a tab contains and how they are arranged.
-- Structural depth themes are bound only by the layout primitives, never by a panel or coordinator. The `TabColumns` scaffold binds each column its declared depth theme — recessed GROUND for a column hosting a stack of floating cards, raised SURFACE for a full-height column that is itself a single docked surface (a file tree, an instrument list) — and the `card()` context manager binds SURFACE to a card. Panels and coordinators bind only semantic/content themes (a per-generator checkbox tint, the player toolbar), never GROUND or SURFACE.
+- Structural depth themes are bound only by the layout primitives, never by a panel or coordinator. The `TabColumns` scaffold binds each column its declared depth theme — recessed GROUND for a column hosting a stack of floating cards, raised SURFACE for a full-height column that is itself a single docked surface (a file tree, an instrument list) — the `card()` context manager binds SURFACE to a card, and `well()` binds recessed GROUND to a padded region sunk inside one, so a list reads as one body rather than as content loose on its card. Panels and coordinators bind only semantic/content themes (a per-channel checkbox tint, the player toolbar), never GROUND or SURFACE.
- Every mutation from outside goes through `update_view(view_model)` or through a direct DPG call (`dpg_configure_item`, `dpg_set_value`) triggered by an `update_*` method.
- Callback wiring from coordinators sets public `on_x` attributes *after* construction; panels must therefore tolerate `None` hooks until wiring is complete.
- A widget whose rendering needs synchronous per-item queries declares a consumer-owned `Protocol` of exactly that surface (e.g. `TreeLogicProtocol`, through which the file trees query per-node favorite and playability state); the owning coordinator constructs the real logic object and injects it, and the panel types against the Protocol. Hooks and view models remain the default — the Protocol is the exception for query-heavy widgets where projecting a whole tree per repaint would be disproportionate.
@@ -215,9 +237,9 @@ They read the source as an AST through the shared layer in `sampletones_shared/m
| Path | Role |
|------|------|
-| `ui/elements/` | Reusable low-level widgets: `GUIPanel` (the panel base class), `GUIWindow` (modal variant), buttons, tables, graphs, trees, fonts, the status bar |
-| `ui/elements/layout/` | Reusable layout primitives: `TabColumns` (the tab column scaffold) and the `card()` context manager, driven declaratively by tab coordinators |
-| `ui/panels/` | Domain-level composite panels, organised by feature area |
+| `ui/elements/` | Reusable low-level widgets: `GUIPanel` (the panel base class), `GUIWindow` (modal variant), buttons, tables, graphs, trees, fonts, the status bar, and `MenuSection` — a run of menu items restated each time its menu is opened |
+| `ui/elements/layout/` | Reusable layout primitives: `TabColumns` (the tab column scaffold), the `card()` context manager and the `well()` inset region, driven declaratively by tab coordinators |
+| `ui/panels/` | Domain-level composite panels, organized by feature area |
| `ui/themes/` | DPG themes and per-widget style helpers |
| `ui/resources/` | Icons and image resources loaded at startup |
| `ui/menu.py` | `MenuBar` — the application's top menu bar |
@@ -280,7 +302,8 @@ They read the source as an AST through the shared layer in `sampletones_shared/m
**Contracts:**
- Every service inherits `ServiceBase[ResultType]`, which provides `subscribe(handler)`, `unsubscribe(handler)`, and `_emit(result)`.
- `_emit` always posts the result to `CallbackQueue`; it never calls a handler directly from the background thread.
-- Result types are a tagged union of `ServiceStarted`, `ServiceProgress`, `ServiceIntermediate`, `ServiceSuccess`, `ServiceError`, `ServiceCancelled`, enabling exhaustive `match` handling by subscribers.
+- Result types are a tagged union of `ServiceStarted`, `ServiceProgress`, `ServiceIntermediate`, `ServiceSuccess`, `ServiceError`, `ServiceCanceled`, enabling exhaustive `match` handling by subscribers.
+- A service is one subpackage holding `service.py` and `result.py`, so its implementation and the contract its subscribers type against are reached separately; the generic contracts every service reports through are `services/result.py`. `ServiceProgress.fraction` is the one reading a bar draws, counting the item under way for the part of it that is done — see `docs/development/progress.md`.
- Services hold no references to panels, view models, or logic objects.
**May import:** `sampletones_core`, `sampletones_shared`, `utils/callbacks/`.
@@ -298,11 +321,11 @@ There are two coordinator kinds:
*Tab coordinators* own everything for one tab: they instantiate its panels, logic objects, and tab-scoped services, wire their callbacks together, and provide `create_tab()` — the single method that builds the DPG widget tree for that tab. Tab coordinators present a narrow public API of intent-level methods (`set_input_path`, `display_reconstruction`, …) and keep their panels and logic objects private.
-`create_tab()` is the sole authority for the tab's layout: it declares the column and card arrangement through the shared `ui/elements/layout` primitives (`TabColumns`, `card()`) and injects each panel's parent container via `create_panel(parent)`. It builds widgets only — initial view population (pushing the first view models, refreshing trees) runs afterwards from the coordinator's post-build initialisation, invoked once the whole tree exists, rather than inside `create_tab()`.
+`create_tab()` is the sole authority for the tab's layout: it declares the column and card arrangement through the shared `ui/elements/layout` primitives (`TabColumns`, `card()`) and injects each panel's parent container via `create_panel(parent)`. It builds widgets only — initial view population (pushing the first view models, refreshing trees) runs afterwards from the coordinator's post-build initialization, invoked once the whole tree exists, rather than inside `create_tab()`.
**Contracts:**
- A coordinator touches DPG only on a narrow, closed surface: inside `create_tab()`, and when building dialog content inside a closure passed to `DialogsRenderer.show_modal`. A dialog that must wait for the next frame is deferred through `FrameCallbackManager`. All other presentation goes through `DialogsRenderer`.
-- File selection runs through OS-native dialogs, which live outside DPG. A coordinator opens one via `utils/file_dialogs` — a synchronous call that blocks until the user picks a path or cancels — resolves the dialog title and filter name from `LanguageManager`, and routes the returned path through a handler decorated with `@ignore_none_path`, so a cancelled dialog is a silent no-op and each handler body runs with a real path. The backend is chosen at runtime; a coordinator never branches on platform.
+- File selection runs through OS-native dialogs, which live outside DPG. A coordinator opens one via `utils/file_dialogs` — a synchronous call that blocks until the user picks a path or cancels — resolves the dialog title and filter name from `LanguageManager`, and routes the returned path through a handler decorated with `@ignore_none_path`, so a canceled dialog is a silent no-op and each handler body runs with a real path. The backend is chosen at runtime; a coordinator never branches on platform.
- A coordinator holds no domain state. It delegates reads and writes to the managers and controllers it was given; what it caches is presentation wiring — resolved language strings, panels, logic objects, callbacks.
- Callbacks received from `Application` as constructor parameters are stored and forwarded as-is. The one sanctioned wrapper is an intent-level guard that a contract requires — e.g. a busy-authority start-time guard (principle 10) wrapping an operation's entry point.
- Error dialogs, confirmations, and notices are presented here, with text resolved from `LanguageManager` here (see the Error Handling Policy).
@@ -330,7 +353,7 @@ There are two coordinator kinds:
`ApplicationShell.setup()` creates the DPG context, registers shortcuts, binds the `KeyRouter`'s single global key-press handler, builds the main window (menu bar + tab bar + status bar), and starts the `CallbackQueue` worker thread. Tab coordinators are passed to the shell so it can call their `create_tab()` methods in sequence.
-**Must not import:** `logic/`, `services/`. The shell reaches domain behaviour only through the coordinators and callbacks it was handed.
+**Must not import:** `logic/`, `services/`. The shell reaches domain behavior only through the coordinators and callbacks it was handed.
---
@@ -339,11 +362,11 @@ There are two coordinator kinds:
| Package | Purpose |
|---------|---------|
| `config/` | `ConfigManager` (domain generation config), `SessionManager` (runtime session: last paths, audio device, window geometry). Presentation-free: it records load outcomes (`ConfigLoadOutcome`) as domain data for `ConfigCoordinator` to present. Must not import the visual packages, `coordinators/`, or `application.py` |
-| `categories/` | `LanguageManager`, the `Page / Panel / TextType / Widget` enum hierarchy, the `AbstractElement` base and the panel element enums under `categories/elements/`, and the key grammar under `categories/key/` |
-| `constants/` | Application-scope facts that carry no behaviour, one module per subject — `keybindings.py` names the scheme a build ships, which both the shortcut catalog and the session config read, and `playback.py` names the follow mode, which the session config, the song player, the view models and the menu all state. A fact shared beyond the application belongs to `sampletones_shared/constants/` |
+| `categories/` | `LanguageManager`, the `Page / Panel / TextType / Widget` enum hierarchy, the `AbstractElement` base and the panel element enums under `categories/elements/`, and the key grammar under `categories/key/`. It also holds the message bundles that resolve a whole conversation's words in one place — `export.py`, `exports.py`, `instrument.py`, `pitch.py` — so a coordinator reads its texts once and hands the bundle to whoever phrases the outcome |
+| `constants/` | Application-scope facts that carry no behavior, one module per subject — `keybindings.py` names the scheme a build ships, which both the shortcut catalog and the session config read, and `playback.py` names the follow mode, which the session config, the song player, the view models and the menu all state. A fact shared beyond the application belongs to `sampletones_shared/constants/` |
| `layout/` | Pydantic models loaded from YAML at startup; injected into coordinators and panels as `LayoutConfig` |
| `tags/` | DPG widget tags (`TAG_*`), the fragments composing into them (`SUF_*`, `PRE_*`), and `compose_tag` |
-| `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 |
+| `utils/` | dpg-free helpers usable by any layer (`utils/callbacks/`, color, 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 |
---
@@ -492,7 +515,7 @@ sampletones_application/
| Service class | `Service` | `ConversionService` |
| DPG widget tag | `TAG_` + the composed tag, upper-cased | `TAG_MAIN_CONFIG_TABLE_CONFIG_ROW` (`main.config.table.config_row`) |
| Tag suffix | `SUF_` | `SUF_PANEL_LEFT` |
-| Tag prefix | `PRE_` | `PRE_RECONSTRUCTION_GENERATOR` |
+| Tag prefix | `PRE_` | `PRE_RECONSTRUCTION_CHANNEL` |
| Text key | `page.panel.text_type.element` | `global.dialog.label.ok` |
| Panel callback hook | `on_` attribute | `on_convert_requested` |
| Panel state hook | `can_` or `_` attribute | `can_add_to_sequencer`, `replace_in_sequencer_label` |
diff --git a/docs/development/browser.md b/docs/development/browser.md
index 0bf2e4d1f..ce82c113e 100644
--- a/docs/development/browser.md
+++ b/docs/development/browser.md
@@ -19,7 +19,7 @@ complements `docs/development/architecture.md` (layering and ownership) and
render one model, so they show one shape, and each rule is exercised without a window.
3. **A row's identity is its path; its name is a label.** Favorites, the context menus, copy-path,
playback and opening a reconstruction all key on `filepath`. That is what frees a name to be
- rewritten — a configuration directory renamed to its generator abbreviation, a chain of headings
+ rewritten — a configuration directory renamed to its channel abbreviation, a chain of headings
joined into one row, a colliding label marked with its configuration hash.
4. **The browser writes the headings the disk states rather than holds.** A frequency pair, a
transformation, a source folder, one source audio: each becomes a row that carries no path of its
@@ -54,8 +54,8 @@ drive, and `get_all_reconstruction_files` reads the scan.
|---|---|---|
| Scan | `tree/scan.py` | `scan_reconstructions` walks the directory once, recording each folder with the configuration its name states and each `.stn` file beneath it |
| Records | `tree/entries/` | `DirectoryEntry`, `ReconstructionEntry`, `ReconstructionScan` — frozen, path-only, no widgets and no tree |
-| Configuration branch | `tree/configurations/` | `branch.py` lays the scanned folders out as they sit; `grouping.py` lifts a top-level configuration directory under frequency ▶ transformation configuration headings and names it by its generators, so the rows leading to it spell its display name; `naming.py` gives the remaining configuration directories friendly names, unique among their siblings |
-| Sample branch | `tree/samples/` | `variants.py` regroups every top-level configuration directory's reconstructions by the audio they mirror (`SampleSource` → `SampleVariant`); `branch.py` rebuilds the mirrored folders as groups and gathers each audio's variants under one sample row, each labelled by its configuration |
+| Configuration branch | `tree/configurations/` | `branch.py` lays the scanned folders out as they sit; `grouping.py` lifts a top-level configuration directory under frequency ▶ transformation configuration headings and names it by its channels, so the rows leading to it spell its display name; `naming.py` gives the remaining configuration directories friendly names, unique among their siblings |
+| Sample branch | `tree/samples/` | `variants.py` regroups every top-level configuration directory's reconstructions by the audio they mirror (`SampleSource` → `SampleVariant`); `branch.py` rebuilds the mirrored folders as groups and gathers each audio's variants under one sample row, each labeled by its configuration |
| Shaping | `tree/prune.py`, `tree/collapse.py`, `tree/order.py` | Run in that order over each branch, deepest rows first |
| Containers | `tree/containers.py` | `find_or_create_group`, `find_or_create_config_group` and `find_or_create_sample` extend the heading of that name a parent already holds; each heading is looked up among the siblings of its own kind and class, so a folder and an audio sharing a name stay two rows |
@@ -95,7 +95,7 @@ configuration is what distinguishes one row from the next.
show stays silent. A folder the disk holds stays, since the configuration branch mirrors the disk.
* **Collapse** (`collapse_single_child_containers`) — a heading standing above a single row folds into
that row, which takes the joined name (`DISPLAY_SEPARATOR` between levels) and rises into its place.
- The surviving row keeps its node type, path, configuration and children, so its click behaviour,
+ The surviving row keeps its node type, path, configuration and children, so its click behavior,
theme, context menu and favorite star carry over. A fold that would repeat a name already beside it
stays open instead, and the branch roots stay in place. With a single configuration present the
configuration branch reads as one row per reconstruction, and it grows back into groups as soon as a
@@ -106,7 +106,7 @@ configuration is what distinguishes one row from the next.
in.
* **Unique sibling labels** (`unique_display_names`, `sampletones_core/configs/display.py`) — where
siblings would read alike, every member of that label takes its short configuration hash. One rule
- serves the generator directories under a transformation group, the nested configuration directories,
+ serves the channel directories under a transformation group, the nested configuration directories,
and the variants under a sample.
## The panels
@@ -122,11 +122,11 @@ The browsers form one line of inheritance, each level owning what it shares:
handler pair, and enabling the card as the tree locks and unlocks. A subclass declares its widgets as
a `FileBrowserTags` class attribute and states what its card and refresh control read.
* `GUIReconstructionBrowserPanel` (`ui/panels/shared/browser.py`) — the reconstruction browser: the
- rows the two branches hold, the colour a group and a sample read in, and the context menus. The
+ rows the two branches hold, the color a group and a sample read in, and the context menus. The
Reconstructions and Sequencer panels below it name their widgets, their refresh control, and what
opening a reconstruction means in that tab.
-The Main tab's filesystem explorer and the Instructions tab's library catalogue sit on
+The Main tab's filesystem explorer and the Instructions tab's library catalog sit on
`GUIFileBrowserPanel` as well, so the card, the search and the rebuild machinery are shared with them.
**A rebuild** starts on the tree worker: `_launch_rebuild` takes the tree lock, brings the model up to
@@ -263,10 +263,10 @@ empty (`global.dialog.message.tree_no_favorites`, `global.dialog.message.tree_no
filter's answer reads where the rows would be.
**The control** is a checkbox under the search box carrying the favorite glyph, which reads in the
-favorite colour while the mode is on and muted while it is off. `_OFFERS_FAVORITES_FILTER` states
+favorite color while the mode is on and muted while it is off. `_OFFERS_FAVORITES_FILTER` states
which cards hold it: the reconstruction browsers, whose rows stand for the paths a session stars. It
follows the tree's lock, a rebuild being what it asks for, and its label reads in the pair every
-checkbox reads — the text colour while it can be clicked, the muted one while a rebuild holds it — so
+checkbox reads — the text color while it can be clicked, the muted one while a rebuild holds it — so
the shade states whether the control is live.
Each browser opens in the mode it was left in. The panel raises `on_favorites_filter_changed` with its
diff --git a/docs/development/bugs-and-todos.md b/docs/development/bugs-and-todos.md
index b5b2a61c7..04a3613e5 100644
--- a/docs/development/bugs-and-todos.md
+++ b/docs/development/bugs-and-todos.md
@@ -10,12 +10,32 @@
* Multiple Reconstruction views
* In-project sample selection in Reconstruction view
* Playing a fragment by clicking on a waveform
-* Note pitch shown as a transpose offset rather than a note name
* Application installation progress bar
### Tracker
-* Basic shapes as instruments
+The first two entries are also what an imported `.fti` reports as left to the file
+(section C of `formats/famitracker.md`), so each one closed is a dimension the import
+starts carrying.
+
+* Release points: `NoteValue.RELEASE` stands in the FamiTracker specification while a note-off cuts
+ the channel. A release segment would need the playback walk, the NSF driver and `NoteOff` to gain
+ one.
+* Arpeggio modes: a sequence's `setting` byte states absolute. Fixed, relative and scheme need an
+ enum of their own, and scheme needs the item bit-packing FamiTracker gives it.
+* The bend reaching the NSF planes. Each tone channel carries a bend plane and the driver adds
+ what it holds to the divider, but the encoders still leave the dimension to the note:
+ `registers/playable.py::playable` states that once, and every encoder below reads frames
+ carrying no bend. Filling the plane means the register encoders holding the bent divider and
+ `PitchTable` naming a divider as the nearest pitch beside a signed residual. Until it lands, an
+ NSF renders a bent note at the note's own divider.
+* The bend in a Bitphase export. `formats/bitphase/envelopes.py` states the three dimensions it
+ writes; `NesInstrumentRow` already carries `tone_add` and `tone_accumulation`, so the mapping is
+ confined to that module.
+* A transpose or a volume typed in the sample column of a row holding no sample reaches every
+ channel. The column summarizes the channels its samples cover, and a row covering none falls
+ back to all four so a value typed there lands somewhere; the reference slot keeps the narrower
+ reading and stays empty.
### Workflow
@@ -27,18 +47,82 @@
* In-application guide/tutorial
* Language selector
-* NSF export
+* Verifying a bend against the criterion. The plan for the refinement carried a guard: render the
+ bent candidate, score it, and keep the bend only where the cost improves. It was measured and
+ left out. The criterion agreed with the reading on **every** bent frame of both a matched and a
+ mismatched target, so the guard rejects nothing; and one extra render-and-score per bent frame
+ measures around **2.1 s per second of audio**, against a whole conversion's ~1.2 s, so it would
+ nearly triple a run to change no decision. It is worth revisiting only against material where the
+ reading is shown to misfire.
+* Reading only the bins the refinement asks for. `InstantaneousPitch` transforms every bin the
+ spectrum covers and then reads five of them per frame, so it computes around twenty times the
+ work its reading uses. On a CUDA build that vanishes; on a CPU build one transform measures a
+ tenth or more of a short conversion, and a CI runner has measured it at a third. The kernel is a
+ matrix of one row per bin, so restricting it to the rows the chosen notes name is a slice — what
+ needs care is that the union of harmonic bins over a whole stream is wider than any one frame's.
+* Calibrating the pitch refinement. `generation.refinement`'s confidence threshold, change weight
+ and window are chosen by hand; `docs/concepts/calibration.md`'s experiment measures the criterion
+ blend and could measure these beside it. The change weight is the one with an audible trade-off:
+ it decides how large a one-frame excursion the walk follows rather than absorbs, which is
+ vibrato against jitter.
### Technical
* API documentation
* Code documentation
-* Backward compatibility: library/reconstruction upgrade scheme
-* Respecting FamiTracker limitations
+* A backward-compatibility corpus of files older builds actually wrote. Every upgrade step is
+ exercised against a payload the test builds itself — hand-written mappings for the step, and,
+ for projects, a current document rewritten backwards into the older shape — so a step is held
+ only to the fields it names. One archived `.stn`, `.ins` and `.stp` per shipped version, each
+ written by that version and exercising every feature it could store, would hold the whole
+ document to the chain and would catch a field that changed shape while no step named it.
+ Configuration and session state carry no version at all, so the same corpus would state what a
+ build is expected to make of a `state.yaml` an older one left behind. Reaches
+ `tests/unit/sampletones_core/compatibility/` and each format's load tests.
+* The element enums that outlived their keys. A lookup states its key literally, so an element
+ enum is named only where a `_label(element)` helper takes one — `ui/menu.py`,
+ `coordinators/project.py`, `coordinators/keybindings.py`, `ui/panels/dialogs/project_properties.py`
+ and the panels beside them. The language-keys check expands such a helper over the whole enum, so
+ a member no call names is reached all the same and stands unnoticed. Spelling those keys literally
+ at the call site would make each entry exactly checkable and retire the enums that remain.
+* Respecting FamiTracker limitations at the writers. A target format's ceilings belong to the
+ code that writes that format: an envelope carries whatever length a reader wrote, and meets a
+ limit where a file is built. `formats/famitracker/sequences/features.py` is where the 252-item
+ sequence ceiling applies today, and it is the one place that decides what a file holds, which
+ both the export's report and the instruments panel's warning read. What is still owed is the
+ same treatment for the ceilings a module carries — the instrument, sequence and pattern counts
+ in `specification/` — so a project past one of them is reported to the reader rather than
+ refused by the writer.
* Per-tab undo routing
* In-application console
* Improve performance of browser favorite scan of the entire tree per click
+## Architecture
+
+Where the codebase stands apart from `docs/development/architecture.md`. A deviation is recorded
+here once review has seen it and let it stand, so this section — rather than the code — is the
+memory of what is currently out of line, and an entry leaves when the code meets the contract
+again.
+
+* The two sequencer grids state the same machinery twice. `ui/panels/sequencer/order/` and
+ `ui/panels/sequencer/tracker/` each divide into a panel and its collaborators, and the panel
+ modules still declare the same block and channel hooks, build the same `ChannelSwitch`, tint a
+ channel the same way, and run the same held-pointer drag, right-click hit-test and key dispatch.
+ `ui/panels/sequencer/grid/` is where both already reach for what they share, and it is where
+ these belong; the `# TODO: to abstract` markers stand at the blocks themselves. Pylint's
+ duplicate-code report names each pair, so the work is enumerable rather than a matter of
+ reading.
+* `application.py` and `ui/elements/tree/tree.py` each hold several concerns in one module, past
+ the size at which the sequencer panels and the sequencer tab coordinator were divided into
+ subpackages. Each divides the same way: a module per concern, with the class that stays holding
+ the collaborators and the public surface.
+* Several directories under `ui/` carry modules without an `__init__.py`, which leaves each one a
+ namespace package. A tool reading the tree treats such a directory as a root it can import from,
+ so a module inside one answers for a standard-library name of the same word: `ui/elements/trace.py`
+ stands against `trace` this way, and `ui/elements/graphs/layers/array.py` did against `array`
+ until it was given a package of its own. Giving each directory an `__init__.py` closes the
+ whole class.
+
## Bugs
* No refreshing after library generation
diff --git a/docs/development/compatibility.md b/docs/development/compatibility.md
new file mode 100644
index 000000000..fca02ffdc
--- /dev/null
+++ b/docs/development/compatibility.md
@@ -0,0 +1,136 @@
+# Data Compatibility
+
+This document governs the version upgrades applied to the stored data formats of
+_SampleToNES_: reconstruction files (`.stn`), instruction libraries (`.ins`), and
+project documents (`project.json`). Consult it when changing a serialized shape,
+adding a format version, or diagnosing a file that loads as incompatible.
+
+The upgrades live in `sampletones_core/compatibility` and run at the load
+boundary of each format, before deserialization. The formats' own documents
+describe their stored shape and versioning:
+
+- [`formats/reconstructions.md`](../formats/reconstructions.md)
+- [`formats/instruction-libraries.md`](../formats/instruction-libraries.md)
+- [`formats/projects.md`](../formats/projects.md)
+
+## Principles
+
+### A format reads and writes one data version
+
+Each format states the single data version this build produces, held in
+`SAMPLETONES_LIBRARY_DATA_VERSION`, `SAMPLETONES_RECONSTRUCTION_DATA_VERSION`,
+and `SAMPLETONES_PROJECT_DATA_VERSION` (`sampletones_shared/application.py`).
+The version travels inside every stored file, and the format's load contract
+holds each file to it: `MetadataContract` for the binary formats, the
+`format_version` check for projects.
+
+### An upgrade is one version step
+
+A stored shape changes in small, named steps. Each step is a `VersionUpdate`:
+the version the payload reads at, the version it writes after the transform, and
+the transform itself. The steps of one format form a chain, registered in
+`compatibility//__init__.py`, and each step lives in a module named
+after the version it writes — `compatibility/reconstruction/v2_2.py` carries
+the step that writes reconstruction data version 2.2.
+
+That step shows the shape a whole step takes: it names each stored stream and
+approximation by its channel, names the embedded config's channel selection the
+same way and stamps that config with the target version, lists the source audio
+as one path per stem, and synthesizes the stems record every 2.2 file carries —
+one stem covering every enabled channel and holding every frame the file plays,
+which is what the conversion that wrote the file did.
+
+### A version belongs to a release
+
+The version a format writes moves once per release. Between releases that
+version is still being written: every file carrying it was written by a working
+tree, so a further change to the stored shape extends the step already pending
+rather than adding a second one, and that step widens to carry the whole
+distance from the version the last release shipped. What a user's files travel
+is therefore one step per release, and `git show :src/sampletones_shared/application.py`
+names the version their files stand at.
+
+### A chain applies whole or not at all
+
+An upgrade runs only when the registered steps form a complete path from the
+file's version to the version this build writes. A file whose version no chain
+reaches comes back unchanged, and the format's load contract refuses it, exactly
+as it refuses any version this build does not support. A partial path leaves a
+file entirely untouched.
+
+### Upgrades run on the raw payload
+
+Upgrades apply to the serialized payload before any model sees it: the msgpack
+mapping for `.stn` and `.ins`, the JSON document for `project.json`. The
+transform steps reshape that payload — renaming the fields whose names changed
+between versions, and adjusting the values they hold where the shape demands it.
+
+### A completed upgrade stamps the version it reached
+
+A payload whose chain ran carries the new version in the same field it declares
+it with, so the file states the version its shape now matches and a later save
+writes that version. The load path leaves the bytes of every other payload
+untouched.
+
+## Mechanics
+
+### Package layout
+
+- `compatibility/kind.py` — `ObjectKind`, the format an upgrade belongs to
+ (`LIBRARY`, `RECONSTRUCTION`, `PROJECT`).
+- `compatibility/update.py` — `VersionUpdate`, one named version step.
+- `compatibility/upgrade.py` — the engine: `upgrade`, `upgrade_binary`,
+ `upgrade_json`, and the per-format registries `CURRENT_VERSIONS` and `UPDATES`.
+- `compatibility//__init__.py` — that format's `UPDATES` tuple. The
+ reconstruction chain currently holds the 2.1→2.2 step
+ (`compatibility/reconstruction/v2_2.py`), the project chain the 1.0→1.1 step
+ (`compatibility/project/v1_1.py`), and the library chain is empty.
+
+### Version fields
+
+- `.stn` — `metadata.reconstruction_data_version`
+- `.ins` — `metadata.library_data_version`
+- `project.json` — `format_version` at the document root
+
+### Load boundaries
+
+`Reconstruction.deserialize_data` and `InstructionLibraryData.load` pass their
+payload through `upgrade_binary`; `ProjectContainer.load` passes the document
+through `upgrade_json`. Each wrapper parses the payload, reads the format's
+version field, runs the chain, and re-encodes the upgraded payload. A payload
+that stays as it is — no chain applies, no version field, or a payload that does
+not parse to a mapping — returns as the same bytes, so the load path behaves for
+it exactly as it did before the upgrades existed. A file whose version no chain reaches arrives at the format's load contract
+unchanged, which refuses it with the format's `Incompatible*VersionError`, as it
+always did.
+
+### Adding an upgrade
+
+Read the format's version constant against the one the last release shipped, and
+take whichever route that comparison names.
+
+**The constant stands where the release left it.** The change opens a new step:
+
+1. Bump the format's version constant in `sampletones_shared/application.py`.
+2. Add the step module named after the new version — e.g.
+ `compatibility/reconstruction/v2_2.py` — with a transform that takes the
+ payload at the previous version and returns it at the new one.
+3. Append the step to the format's `UPDATES` tuple.
+4. Cover the step with unit tests under
+ `tests/unit/sampletones_core/compatibility/`, and with a loader test that
+ opens a payload written at the previous version.
+
+**The constant already stands ahead of the release.** The pending step is the
+one to widen: fold the new transform into the module named after that version,
+state the whole step from the shipped version in its docstring, and extend its
+tests to cover what was added. The version constant stays where it is.
+
+The engine stamps the new version once the chain runs, so a step module declares
+only its own transform.
+
+## Verification
+
+- `uv run pytest tests/unit/sampletones_core/compatibility` covers the engine and
+ every registered step.
+- The format load tests open payloads at previous versions and hold the loaded
+ models against the current shape.
diff --git a/docs/development/config-organization.md b/docs/development/config-organization.md
index fb4df14cb..d2994b588 100644
--- a/docs/development/config-organization.md
+++ b/docs/development/config-organization.md
@@ -10,7 +10,8 @@ is read; use it as the reference when adding or moving a value. It sits alongsid
first:
- **Shipped configuration** — the `sampletones_config` YAML package: layout, theme,
- palettes, keybindings, language, behavior, deployment, and calibration. *(This document.)*
+ palettes, keybindings, language, behavior, deployment, calibration, and the import
+ boundaries. *(This document.)*
- **Runtime user preferences** — mutable state persisted to the user profile
(`sampletones_application/config`, e.g. `PlaybackConfig`, `ShortcutsConfig`,
`ApplicationState`), governed by that package.
@@ -32,8 +33,8 @@ empty `__init__.py`. Each schema lives with its reader:
- `sampletones_application` owns the layout, theme, palettes, keybindings, language,
behavior, and deployment schemas.
- `sampletones_core` owns the calibration schemas.
-- `sampletones_shared` owns the loader primitives (`load_yaml_model`,
- `load_yaml_model_dir`).
+- `sampletones_shared` owns the import-boundary schemas and the loader primitives
+ (`load_yaml_model`, `load_yaml_model_dir`).
So the data carries the values and the consumer carries the meaning, and the two evolve
on their own terms.
@@ -41,12 +42,13 @@ on their own terms.
### 2. The top level is organized by domain
`sampletones_config` has one top-level directory per schema family and its loader:
-`application`, `behavior`, `calibration`, `keybindings`, `lang`, `layout`, `palettes`,
-`theme`. Each domain owns its schema and its load path (see [Domains](#domains)). A new domain
-is a new top-level directory with its own schema owner and loader.
+`application`, `behavior`, `boundaries`, `calibration`, `keybindings`, `lang`, `layout`,
+`palettes`, `theme`. Each domain owns its schema and its load path (see
+[Domains](#domains)). A new domain is a new top-level directory with its own schema owner
+and loader.
-Palettes are a domain of their own because two other domains resolve against them: a colour
-field in `layout/` and a colour entry in `theme/` both name a palette token, and the palette
+Palettes are a domain of their own because two other domains resolve against them: a color
+field in `layout/` and a color entry in `theme/` both name a palette token, and the palette
is what turns that name into a value. A directory holds one file per palette, named after the
palette it declares, and every palette answers the same token set — an entry names one token
and each palette must have an answer for it.
@@ -112,7 +114,7 @@ receives a view built for it:
`parameters/` package gathers exactly what one tab needs: the shared six-field
`TabGeometry` core, the flat integers its primitive sinks consume, and the cohesive
feature models it forwards whole (`SchedulingBehavior`, `GraphsLayout`, the tab's own
- `Layout`, the colour blocks). A small factory produces any narrowed slice a consumer
+ `Layout`, the color blocks). A small factory produces any narrowed slice a consumer
needs (`TreeColors.create`, `PitchStepperStyle.from_general`).
The type signals which side of the boundary a value is on: a frozen Pydantic model with
@@ -130,6 +132,7 @@ each value sits in the tree stays in the factory.
|--------|-----------|--------------|------------------|
| Application | `application/` | `DeploymentConfig` (`sampletones_application/config/deployment/`) | `DeploymentConfig.load()`, with `SAMPLETONES_*` env overrides |
| Behavior | `behavior/` | `BehaviorConfig` (`sampletones_application/layout/behavior.py`) | folded into `LayoutConfig.behavior` by `load_layout_config` |
+| Boundaries | `boundaries/` | `ImportBoundaryRules` (`sampletones_shared/meta/import_boundary/configs/`) | `ImportBoundaryRules.load()` |
| Calibration | `calibration/` | `CorpusConfig`, `RefereeConfig` (`sampletones_core/calibration/config/`) | each model's own `.load()` |
| Keybindings | `keybindings/` | `ShortcutScheme` (`sampletones_application/utils/gui/shortcuts/`) | `ShortcutCatalog.load()`, indexed by scheme name |
| Language | `lang/` | `LanguageManager` (`sampletones_application/categories/`) | flat string map keyed `page.panel.text_type.element`, each key validated at load |
@@ -138,7 +141,7 @@ each value sits in the tree stays in the factory.
| Theme | `theme/` | `ThemeSpec` (`sampletones_application/ui/themes/spec.py`) | `ThemeLoader.load_all()` → `ThemeRegistry` |
The palettes load first, and the source holding the active one is injected as validation
-**context**, so any colour field in layout or theme keeps the token it was written as and
+**context**, so any color field in layout or theme keeps the token it was written as and
reads its value from the palette in place when it is drawn with. `PaletteCatalog` names the
palette a preference selects and answers with the default (`studio`) for a name the build
does not ship, so a preference outlives the build that wrote it.
@@ -165,6 +168,14 @@ consumers reach it as `layout.behavior.*`. A single access path serves the ~15 r
sites across `application.py` and the tab coordinators that read it, and the ~10 modules
that import `SchedulingBehavior` as a type.
+Boundaries is the domain a developer tool reads. It states the layer graphs the packages
+divide into, the imports each part of the application stays clear of, and the spellings a
+tree keeps out, and `scripts/checks/import_boundary.py` runs it over the source tree on every
+commit. A declaration draws on the named prefix groups `general.yaml` holds, so a set several
+rules reach for is written once and each rule names it, and a name reaching no group is
+refused as the domain is read. The bundle carries the domain because `--add-data` copies
+`sampletones_config` whole — the terms `calibration/` already ships on.
+
---
## Loading
@@ -189,6 +200,6 @@ Three load mechanisms serve the three grouping schemes:
file on disk. `ShortcutCatalog.load()` reads `keybindings/` the same way, keyed by
`ShortcutScheme.name`.
-Deployment and calibration each load through a bespoke `.load()` classmethod over
-the same low-level primitives in `sampletones_shared/utils/serialization.py` — the one
-module that calls `yaml.safe_load`.
+Deployment, calibration and the boundaries each load through a bespoke `.load()`
+classmethod over the same low-level primitives in
+`sampletones_shared/utils/serialization.py` — the one module that calls `yaml.safe_load`.
diff --git a/docs/development/dependencies.md b/docs/development/dependencies.md
index e0de82837..103d68085 100644
--- a/docs/development/dependencies.md
+++ b/docs/development/dependencies.md
@@ -54,7 +54,7 @@ Dialogs open through the XDG desktop portal (`org.freedesktop.portal.FileChooser
## Application icon
The icon suite in `src/sampletones_assets/icons` is generated from the mark declared beside it in
-`src/sampletones_assets/mark`: `mark.yaml` carries the geometry, colours and rasterization
+`src/sampletones_assets/mark`: `mark.yaml` carries the geometry, colors and rasterization
settings, validated as a `Mark`, and `template.svg` is the vector the rendered geometry fills. The
package writes the whole suite — the vector `sampletones.svg` and the rasters the application
ships, `sampletones.png` and the multi-resolution `sampletones.ico` — and `scripts/assets/icons.py`
@@ -72,6 +72,65 @@ installed, and PyInstaller follows that import into the bundle. The application
files, so the exclusion spares every bundle Pillow's extension modules and the imaging libraries
that come with them. `scripts/ci/checks/bundle.py` holds the release bundles to it.
+## NES player driver
+
+The player that runs on the console is 6502 assembly, and `src/sampletones_player/driver` holds it
+in three parts: `assembly/` carries the sources, their includes and the linker configuration,
+`binary/` carries the assembled `driver.bin`, and `assembler/` carries the Python that turns one
+into the other. `make player` runs `scripts/player.py` over that package, so the build behaves the
+same on every system the project supports.
+
+Assembling needs `ca65` and `ld65` from [cc65](https://cc65.github.io/) — on Debian and Ubuntu,
+`sudo apt install cc65`, and a build names the equivalent for whichever system it runs on when the
+programs are absent. cc65 is a build-time tool for the driver alone.
+
+The assembled `driver.bin` is committed, so a checkout carries the player and exporting an NSF
+needs no assembler. A jump table leads the image, which fixes the addresses an NSF header names
+whatever the driver's length, so the exporter states them from `specification/driver.py` and a
+build holds the linker's own labels to them before it writes anything. Editing the assembly means
+running `make player` again and committing what it writes; the driver's test suite rebuilds the
+sources and holds the committed image to them wherever cc65 is installed. The wheel carries the
+assembled image alone, which is all an installed copy reads.
+
+cc65 is distributed under the zlib license, and the driver stays clear of it: the link line names
+our own object files and our own `nsf.cfg`, so nothing of cc65's start-up code or libraries reaches
+the committed image. That keeps the blob entirely ours to ship under the project's MIT license.
+
+### Verifying the driver
+
+`tests/integration/nsf` runs an exported file the way a console runs it. [py65](https://github.com/mnaberez/py65)
+— a 6502 emulator in the `dev` dependency group — executes the assembled driver against memory that
+watches the APU's address range, so each routine answers with the register writes it made and the
+suite holds the whole run against `RegisterTrace.from_song`. Reading those writes back into
+instructions and rendering them through the project's own generators closes the loop on the sound
+as well: what the console plays stands against the very waveform the reconstruction carries. py65
+is a developer dependency, outside both the wheel and the bundles, and its BSD license leaves the
+project's own terms untouched.
+
+Listening to a real APU needs [ffmpeg](https://ffmpeg.org/) carrying the `libgme` demuxer, which
+is a build option rather than a given: `make nsf-render` asks the installed ffmpeg which demuxers
+it holds and names this system's install command before it decodes anything. It exports the example
+files and renders each one to a wave beside it, its length read out of the song block the file
+carries. That is an ear rather than a gate: the register trace is what the driver answers to, and
+the wave is what a person listens to.
+
+### The player's tools
+
+Three tools serve the player, each reached by one command:
+
+| Tool | Run by | Installed with | Reaches |
+| --- | --- | --- | --- |
+| cc65 (`ca65`, `ld65`) | `make player` | the system's package manager | the machine assembling the driver |
+| py65 | `make test` | `uv sync --group dev` | the `dev` dependency group |
+| ffmpeg with `libgme` | `make nsf-render` | the system's package manager | the machine listening to an export |
+
+`scripts/linux/build/dependencies.sh` and its macOS counterpart carry what building and running the
+application needs, and the workflows install the `dev` group, so py65 is the one of the three CI
+reaches — the suite verifies the driver through it alone. cc65 and ffmpeg stay on the machine of
+whoever runs `make player` or `make nsf-render`, and a workflow that assembles the driver or renders
+a wave is what would put them in those scripts. The application itself calls neither: an export is
+written by the package's own code, from the committed `driver.bin`.
+
## 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.
diff --git a/docs/development/guidelines.md b/docs/development/guidelines.md
index 0d2277aae..4d58745b5 100644
--- a/docs/development/guidelines.md
+++ b/docs/development/guidelines.md
@@ -19,6 +19,7 @@ These rules govern the Python in this repository. They complement
1. Prefer `pathlib.Path` over `os.path`.
1. Separate function options with `*`, and choose positional arguments intentionally.
1. Change internal APIs, configs, and data shapes freely; preserve backward compatibility only when the user explicitly asks.
+1. Move a stored data version once per release. The version a build writes between releases is still being written, so a further change to that format extends the upgrade step already pending — one step carries the whole distance from the version the last release shipped. See `compatibility.md`.
1. Run `pre-commit` on new files after each change.
## Ownership
@@ -33,8 +34,8 @@ These rules govern the Python in this repository. They complement
1. If a module contains many class and function definitions, split into a subpackage divided by a single concern.
1. If a private function (or public that does not have any external consumers) serves only a class in the module it lives, move it to the class as a static/class method or isolate helper functions into a separate utility module.
1. Prefer subpackages over a flat directory structure.
-1. Isolate platform-, desktop-, or external-tool-specific behaviour behind a `Protocol` with one implementation per target, selected by a runtime factory that probes availability and environment. Callers depend only on the `Protocol` and stay platform-agnostic.
-1. Wrap a third-party library or OS tool whose behaviour differs across platforms behind our own typed interface, and encode each quirk inside the matching implementation. A comment naming the third-party behaviour is warranted there.
+1. Isolate platform-, desktop-, or external-tool-specific behavior behind a `Protocol` with one implementation per target, selected by a runtime factory that probes availability and environment. Callers depend only on the `Protocol` and stay platform-agnostic.
+1. Wrap a third-party library or OS tool whose behavior differs across platforms behind our own typed interface, and encode each quirk inside the matching implementation. A comment naming the third-party behavior is warranted there.
## Type Hints
@@ -43,6 +44,7 @@ These rules govern the Python in this repository. They complement
1. Write type names unquoted, using `from __future__ import annotations` (only when needed), `Self`, or `TYPE_CHECKING`.
1. Reserve `Any` and `object` for boundaries that genuinely accept arbitrary data.
1. Cast or silence a type error only at an untyped or mistyped third-party boundary.
+1. Stub an untyped dependency under `stubs/` when our own classes derive from it. A class deriving from an untyped one reads as `Any` throughout, so the stub is what holds that class and every reader of it to the attributes it carries — `stubs/anytree` does this for the tree nodes.
1. Validate with `mypy`.
## Error Handling
@@ -62,7 +64,7 @@ These rules govern the Python in this repository. They complement
## Docstrings and Comments
1. A docstring explains the intention of a class or function and the context of its use.
-1. State functionality in positive terms. Describe what a class or function *does* — not what it avoids, omits, skips, differs from, or no longer does. Reframe every negation ("does not", "rather than", "instead of", "without", "never", "cannot", "no longer") into the behaviour that actually happens. Do not contrast with rejected alternatives as justification; the positive statement carries the meaning.
+1. State functionality in positive terms. Describe what a class or function *does* — not what it avoids, omits, skips, differs from, or no longer does. Reframe every negation ("does not", "rather than", "instead of", "without", "never", "cannot", "no longer") into the behavior that actually happens. Do not contrast with rejected alternatives as justification; the positive statement carries the meaning.
1. Negative phrasing is allowed only where the condition itself is the contract: exception triggers in `Raises:` clauses, precondition/postcondition bounds (prefer "must be at least X" over "cannot be less than X" where natural), and documented edge-case returns. Outside these concrete cases, negative descriptions are information noise and must be removed.
1. Justify an arbitrary choice in the docstring rather than a code comment, and frame the justification by what the choice achieves.
1. Let clear names carry the meaning, and skip comments or docstrings that restate the code.
@@ -80,18 +82,20 @@ These rules govern the Python in this repository. They complement
1. Write for a reader who never saw the history. A document is not a changelog or a devlog: do not argue against past states, resolved problems, or rejected alternatives the reader never knew existed. The design as it stands carries its own justification; history belongs in commit messages and release notes.
1. Reach for a negative example only when the contrast teaches something the positive statement cannot, and use it sparingly. One well-placed "what to avoid" illuminates; a document written mostly in negatives is noise.
1. State each fact once, in the document that owns it, and cross-reference sibling documents rather than repeating them.
+1. Use American English.
## Tests
1. A test file mirrors the ownership of the code it exercises.
1. When functionality moves between packages, move its direct unit tests in the same change.
+1. **A test whose assertion is a measured duration lives in `tests/benchmarks/`.** The gated suite runs across six workers and under coverage, which multiplies the cost of the code being measured, so those tests run in a pass of their own — serial and uncovered — where the reading is the code's own cost. `make test` runs that pass after the covered one, and `make benchmarks` runs it alone.
1. Parametrize tests that share a body, using a test-case dataclass.
1. Test case classes and cases themselves should be defined inside the testing class, unless these objects are shared between test classes. A suite inherits from `BaseTestSuite` and names its case class `TestCase`, which inherits from `BaseRegularTestCase`, or from `BaseAutolabelTestCase` where the case derives its own label. The parametrized argument carries the case as `test_case`.
1. For a multi-step scenario, use a test-scenario suite class — a series of functions with assertions.
1. Prefer fixtures over factories, and define shared fixtures in an appropriate place.
-1. **A shipped value is a choice, not a contract.** Defaults, keybinding schemes, palettes, layouts, and the settings a build opens on are tuned freely, so a test that restates one turns every adjustment into a test edit. Assert behaviour instead: validation bounds, serialization round-trips, fallback and recovery paths, and the invariants a value satisfies — a default lies within the range offered, every palette declares the same tokens, every action the application names is answered.
-1. **Read a configured value; do not repeat it.** Where a case needs the keys an action answers, a palette's colour, a layout's dimension, or a default a model falls back to, it reads that value from the configuration under test and derives the rest of the case from it. A case that presses a key states which action it is pressing, resolves the combination from the scheme, and keeps passing once that action is rebound.
+1. **A shipped value is a choice, not a contract.** Defaults, keybinding schemes, palettes, layouts, and the settings a build opens on are tuned freely, so a test that restates one turns every adjustment into a test edit. Assert behavior instead: validation bounds, serialization round-trips, fallback and recovery paths, and the invariants a value satisfies — a default lies within the range offered, every palette declares the same tokens, every action the application names is answered.
+1. **Read a configured value; do not repeat it.** Where a case needs the keys an action answers, a palette's color, a layout's dimension, or a default a model falls back to, it reads that value from the configuration under test and derives the rest of the case from it. A case that presses a key states which action it is pressing, resolves the combination from the scheme, and keeps passing once that action is rebound.
1. **A literal shipped value needs a stated reason.** Write one only where the value itself is the contract — a file format's constant, a value another system reads back, an interoperability requirement — and say so in the case. Asserting against the named constant that defines the value (`DEFAULT_MAX_FPS`, `DEFAULT_SCHEME_NAME`) states where the value comes from and is welcome; a bare literal standing for the same thing is the pin this rule forbids.
1. Values that must match by contract are asserted to match, never hardcoded — e.g. project metadata at creation or after a save/load round-trip is held against its source, never against a version string.
1. Unit tests may mock system boundaries (file I/O, external services, IPC channels), but must not mock the domain logic that is the subject of the test. Integration tests must exercise real computation pipelines against real (synthetically built) data.
-1. When a test expectation diverges from the production code's actual behaviour, determine which is wrong before acting. A failing test is evidence of a potential bug in the production code unless the test itself is demonstrably incorrect (wrong imports, misread API contract, incorrect fixture). Never silently delete or weaken a test to make it pass. If uncertain, flag the divergence explicitly and ask before changing either side.
+1. When a test expectation diverges from the production code's actual behavior, determine which is wrong before acting. A failing test is evidence of a potential bug in the production code unless the test itself is demonstrably incorrect (wrong imports, misread API contract, incorrect fixture). Never silently delete or weaken a test to make it pass. If uncertain, flag the divergence explicitly and ask before changing either side.
diff --git a/docs/development/packages.md b/docs/development/packages.md
new file mode 100644
index 000000000..f246dd5a8
--- /dev/null
+++ b/docs/development/packages.md
@@ -0,0 +1,126 @@
+# Package Layers
+
+_SampleToNES_ is one repository holding several packages under `src/`, ordered so that dependencies
+run one way. This document states that order, what each package is for, and how the console player
+is layered inside it. It is prescriptive: `sampletones_config/boundaries/graphs.yaml` restates these
+tables in the form the import-boundary check runs on every commit, and a divergence between this
+document and that configuration is itself a defect.
+
+The layering of `sampletones_application` has its own document,
+[`architecture.md`](architecture.md), which the same check enforces. How a long operation reports how far it
+has come — inside one process and across the pool's workers — is [`progress.md`](progress.md).
+
+---
+
+## The package graph
+
+```mermaid
+graph TD
+ ENTRY["sampletones\n(entry point)"]
+ APP["sampletones_application\n(GUI)"]
+ PLAYER["sampletones_player\n(NES player)"]
+ CORE["sampletones_core\n(reconstruction engine)"]
+ SYNTH["sampletones_synthesis\n(waveform synthesis)"]
+ ASSETS["sampletones_assets\n(mark and fonts)"]
+ SHARED["sampletones_shared\n(facts and helpers)"]
+ CONFIG["sampletones_config\n(shipped YAML)"]
+
+ ENTRY --> APP
+ ENTRY --> CORE
+ APP --> PLAYER
+ APP --> CORE
+ PLAYER --> CORE
+ CORE --> SYNTH
+ ASSETS --> SHARED
+ SYNTH --> SHARED
+ CORE --> SHARED
+ PLAYER --> SHARED
+ APP --> SHARED
+ ENTRY --> SHARED
+```
+
+| Package | Purpose | May import |
+|---------|---------|------------|
+| `sampletones_shared` | Facts and helpers any package holds: constants, exception families, paths, the logger, the array backend, the source layer the checks read the tree through, and the schema these boundaries are declared in | — |
+| `sampletones_config` | The shipped YAML — layout, palettes, themes, keybindings, language, calibration, and these boundaries themselves — reached as package data rather than by import | — |
+| `sampletones_assets` | The application mark and the bundled fonts, with the code that draws the mark | `sampletones_shared` |
+| `sampletones_synthesis` | Analytic waveform synthesis: oscillators, envelopes, layers and voices | `sampletones_shared` |
+| `sampletones_core` | The reconstruction engine, the project model, playing a song out into instructions, and the tracker export formats | `sampletones_shared`, `sampletones_synthesis` |
+| `sampletones_player` | The NES player: the register model, the re-clocking schedule, the 6502 driver and the NSF file | `sampletones_shared`, `sampletones_core` |
+| `sampletones_application` | The DearPyGui front end | `sampletones_shared`, `sampletones_core`, `sampletones_player` |
+| `sampletones` | The command-line entry point and the startup self-check | `sampletones_shared`, `sampletones_core`, `sampletones_application` |
+
+Third-party imports are the package author's own choice and stand outside this table.
+
+**The reconstruction engine stands below the console player.** A reconstruction is produced, saved
+and exported to a tracker with `sampletones_player` absent from the process, which is what lets the
+player's format move while the engine holds still. The consequence is that an export backend
+reaching the console — the seam `sampletones_core/exports/backend.py` describes — is registered
+from above rather than from the engine's own registry.
+
+**A song is played out once, for every reader of it.** Turning an arrangement into the
+instruction each channel sounds on each engine tick — the order walked frame by frame, a row's note
+column starting a voice, its transpose and volume bending what that voice carries, a voice with a
+loop point circling where one without falls silent — is `sampletones_core/performance/`. One
+reading answers for both kinds of voice: a sample plays the frames its conversion found for the
+channel, a hand-written instrument the frames its envelopes make of it. The sequencer renders
+those instructions to audio and the player encodes them into register values, so what a listener
+hears and what the console plays are the same walk read two ways rather than two implementations of
+one rule. A voice sounded on its own — a preview, an audition at a note a key names — takes the
+same two steps a row takes, so it lives there too rather than beside whichever surface asked.
+
+**Equal temperament sits at the bottom.** The MIDI pitch limits and the A4 reference are
+`sampletones_shared/constants/music.py`, and the pitch-to-frequency conversion they govern is
+`sampletones_shared/utils/frequencies.py` — so the synthesis package reads them without reaching up
+into the engine, and `sampletones_core/utils/frequencies.py` keeps what is the engine's own: the
+project's usable pitch range, the noise periods, and the note and period names.
+
+---
+
+## Inside `sampletones_player`
+
+The player divides into units layered the same way, and for the same reason: a register value, a
+clock and a song exist independently of the file they are written into or the driver that reads
+them.
+
+| Unit | Purpose | May import |
+|------|---------|------------|
+| `specification/` | The register addresses, control bits, offsets and address constants the format is written by, one module per subject | — |
+| `clock/` | `PlaySchedule` and `FixedPointStep` — the engine ticks one play call advances a stream by | `specification/` |
+| `registers/` | The per-tick register values each channel plays, and the four streams together | `specification/` |
+| `compression/` | The planes a song separates into, the dictionary its tokens name, and the codec that reads them both ways | `specification/`, `registers/` |
+| `song.py` | `Song` — the compressed planes, the timer table, the schedule and the loop point as one value | `clock/`, `registers/`, `compression/` |
+| `builder.py` | The song a reconstruction or an export request plays as, its instructions encoded, its planes compressed and its rate scheduled | `song.py`, `registers/`, `clock/`, `compression/` |
+| `trace/` | `RegisterTrace` — what the driver is expected to write, call by call | `song.py`, `specification/` |
+| `nsf/` | The song block, the header and the `.nsf` file the console loads | `song.py`, `specification/`, `compression/`, `driver/` |
+| `driver/` | The assembled 6502 driver and the addresses its build reports | `specification/` |
+| `driver/assembler/` | The cc65 build: the layout, the toolchain, the linker map reader and the builder | `driver/`, `specification/` |
+| `export.py` | `NSFBackend` — the export seam answered in `.nsf` files, holding the driver every one of them carries and saying which stage a run is in | `builder.py`, `nsf/`, `driver/`, `compression/` |
+
+### The build toolchain is a developer tool
+
+`driver/assembler/` runs `ca65` and `ld65` over `driver/assembly/` to produce the committed
+`driver/binary/driver.bin`. It is reached from `scripts/player.py` and from the tests, and the wheel
+carries the binary alone — so a module of the shipped tree that imported it would break an installed
+copy, and no unit above declares it. The developer toolchain it needs is described in
+[`dependencies.md`](dependencies.md).
+
+---
+
+## Enforcement
+
+`sampletones_config/boundaries/graphs.yaml` declares both graphs as layer tables — each unit and the
+units it may import — and the rule the check runs derives from them: every unit a table leaves out is
+out of reach, so an edge is declared before it is taken. The hook audits the whole source tree on
+every commit (`make check-import-boundary`), which means adding an edge to a table is how a new
+dependency is opened, and removing one enumerates the work of closing it.
+
+A graph answers for its own well-formedness as it is read: a unit reaching a unit the graph leaves
+undeclared is refused, and so is a graph whose units reach themselves, since a unit's layers state a
+level only where the units stand in an order.
+
+Three parts share the work. `sampletones_config/boundaries/` states what the boundaries are.
+`sampletones_shared/meta/import_boundary/` validates that statement and holds the mechanism —
+reading a module line by line, resolving a unit to the modules it owns, deriving a rule from a graph
+and reporting what crosses it — beside the source layer the other checks read the tree through.
+`scripts/checks/import_boundary.py` runs them over a source tree and prints what they find.
diff --git a/docs/development/playback.md b/docs/development/playback.md
index 6446cd8b5..b4a3886a8 100644
--- a/docs/development/playback.md
+++ b/docs/development/playback.md
@@ -22,7 +22,7 @@ a control over what is heard. The contracts here bind every tab and every player
given screen every time.
4. **Surfaces describe the target; the transport decides.** The menu, the toolbar, and the keyboard
reach identical verbs and report identical state, so a new surface adds another way in to the
- same behaviour.
+ same behavior.
5. **Listening choices stay out of the document.** What the user chooses to hear is session state;
what the project holds is the whole song. Saving, export, rendering, and history read the
document, so each of them works on the full song whatever the user is listening to. A render
@@ -32,18 +32,40 @@ a control over what is heard. The contracts here bind every tab and every player
sound as it renders, so a change is heard as the render-ahead buffer drains. This is what lets a
listening control take effect inside the sound already playing.
7. **A row's duration belongs to the song, not to the player.** How long a row lasts follows from
- the project's tempo and metre together with the row's place in the pattern, so it is a function
+ the project's tempo and meter together with the row's place in the pattern, so it is a function
of position: the same row lasts the same time however playback reached it, and a module exported
from the song can state the same figures. The integer tick counts the groove places *are* the
- tempo, so a render realises them exactly at every rate it offers.
+ tempo, so a render realizes them exactly at every rate it offers.
## Two kinds of sound
-**Preview** — a quick audition fired by a click: a file or reconstruction in a browser tree, a
-sample in the sequencer. A preview is ephemeral. It sounds once, holds the device without claiming
+**Preview** — a quick audition fired by a click or a key: a file or reconstruction in a browser
+tree, a voice in the sequencer, or the instrument the Reconstructions tab has open, sounded at the
+note a piano key names. A preview is ephemeral. It sounds once, holds the device without claiming
ownership of it, and is meant to be heard and forgotten. It yields the device to intentional
playback, and it answers to Stop.
+An instrument's audition is a preview of that kind. It reads the generator chosen on the
+instruments card, takes the note from the keyboard's two octaves above the octave the tracker
+types in, and renders the voice through the same two steps a tracker row takes — the step from the
+instrument's own pitch to the note, at full volume. The plot card draws the same rendering at the
+pitch the instrument stands at, so what is seen and what is heard name one generator.
+
+**A voice sounded on its own runs to its release, or to the length it is offered.** A row holds a
+voice for as long as the pattern asks, while an audition and the voice list's preview have no row
+behind them, so each states a span of its own. A volume dimension ending at silence releases the
+voice, and that release is where the sound stops; one circling from a loop point never reaches a
+last item, so it sounds for the ticks `AUDITION_TICKS` offers it. Both spans are counted by
+`audition_ticks` (`sampletones_core/performance/audition.py`), so the plot card draws exactly the
+frames the keyboard sounds.
+
+**A sounding voice is marked where it has reached.** The device reports its position while it
+plays, and the plot card carries that mark along the voice it drew, the same mark a reconstruction's
+playback moves. A preview follows its own sound alone: `play` reports whether the request took the
+output, and the audition starts following only when it did, so one that yields to playback the
+reader asked for leaves that playback's mark where it is. The device reports a final zero as it
+winds down, which takes the mark off the card.
+
**Intentional playback** — the audio a tab is built around: a reconstruction's audio, an
instruction's audio, or the sequencer song. It is owned by the source that started it, and it is
resumable, seekable, and stoppable. One intentional source at most is engaged at any moment.
@@ -79,7 +101,7 @@ sounding preview whenever the active tab has a source of its own, so a preview a
The transport's verbs are reached identically from the Playback menu, the toolbar, and the keyboard:
-| Key | Command | Behaviour |
+| Key | Command | Behavior |
|-----|---------|-----------|
| `Space` | Play / Pause | Acts on the target: pauses or resumes it while it is engaged, and starts it from the beginning otherwise. With no target, it does nothing. |
| `Shift+Space` | Play from start | Starts the active tab's source from the beginning. |
@@ -141,18 +163,18 @@ keeps the gestures consistent: any way of reaching "silence the rest" leaves the
other.
Every surface shows that one set and switches it. In the tracker a channel recedes down its column;
-in the order table it recedes along its row; both take their shades from one pair of colours, so a
+in the order table it recedes along its row; both take their shades from one pair of colors, so a
silenced channel looks the same wherever it appears. A channel's name is the switch in both tables —
click to silence, modified click to solo, the master name for the whole mix — and both tables hand
the gesture and its right-click menu to one object, so both offer the same wording and the same
-behaviour. The Playback menu's **Channels** submenu carries the same set as a check per channel, plus
+behavior. The Playback menu's **Channels** submenu carries the same set as a check per channel, plus
one item that returns the whole mix. Each of those items is registered as an action whether or not a
key is bound to it, so the keybinding scheme can give it one and the menu prints what the scheme
says (architecture principle 12).
The mask is pulled per rendered row, which is principle 6 for this control: a channel drops in or
out as the render-ahead buffer drains, with the immediacy every other live edit has. A silenced
-channel still takes each row's instrument, transpose, and volume, so returning it to the mix resumes
+channel still takes each row's voice, transpose, and volume, so returning it to the mix resumes
on the state its pattern has reached.
Muting is monitoring, and principle 5 governs what follows. The project holds every channel, so
@@ -207,10 +229,10 @@ the moment its dialog opens until that dialog closes, and it joins the same busy
conversion and library generation, so each of the three holds the others off and every surface
offering one reads a single answer.
-The write itself takes one pass, or two where the user asks for a normalised peak: the first pass
+The write itself takes one pass, or two where the user asks for a normalized peak: the first pass
spills raw samples and discovers the peak, the second reads them back and encodes at the scale that
peak sets. Each pass names itself, so the bar crosses one axis — samples — twice, holding a single
-unit across both. A cancel is honoured between rows and between encoded blocks, and a render that
+unit across both. A cancel is honored between rows and between encoded blocks, and a render that
is stopped or fails clears the destination and the spill, so a result names a path where a finished
file stands.
@@ -256,7 +278,7 @@ terminating would reclaim.
| The document a kernel reads, live or captured | `ProjectSource` / `ProjectSnapshot` (`logic/shared/project_source.py`) |
| The ticks the order lasts and the samples they span | `SongLength` (`logic/sequencer/playback/synthesizer/length.py`) |
| Rendering the song to a file, its passes and its progress | `SongRenderService` (`services/render/`) |
-| Where a rendered file's samples go, normalised or direct | `RenderSink` (`services/render/sink.py`) |
+| Where a rendered file's samples go, normalized or direct | `RenderSink` (`services/render/sink.py`) |
| The choices a render is made under, and the phase it is in | `SongRenderLogic` (`logic/render/`) |
| The formats a file may be written in, and what each accepts | `sampletones_core/audio/writers/` |
diff --git a/docs/development/player.md b/docs/development/player.md
new file mode 100644
index 000000000..b3fcf59db
--- /dev/null
+++ b/docs/development/player.md
@@ -0,0 +1,170 @@
+# The console player
+
+This document governs `sampletones_player`: the 6502 driver an exported `.nsf` carries, the
+codec that fits a song into the console's program area, and the chain that holds both to
+what the application plays. Read it before changing the assembly under
+`driver/assembly/`, anything under `compression/`, or the way a song is built in
+`builder.py`. The byte layout the two sides meet on is [the NSF format](../formats/nsf.md);
+where the package sits among the others is [package layers](packages.md).
+
+Everything else _SampleToNES_ exports describes a song to a program that plays it. This one
+**is** the program. That single difference sets the whole design: the file has to carry a
+player, the player has to fit beside the song in 32 KB, and the song has to be decodable by
+a processor that has no multiply.
+
+## Principles
+
+**The driver interprets nothing.** Which value silences a channel, how a duty cycle reaches
+its bits, how a pitch becomes a period, how the linear counter is held — every one of those
+is settled in Python, under `registers/`, where it is testable at a keystroke. What crosses
+into assembly is moving bytes to addresses and counting ticks. A rule that would have to be
+debugged on a 6502 is a rule in the wrong place.
+
+**What a correct driver writes is stated in Python, and the assembly is held to it.**
+`RegisterTrace.from_song` says which APU registers a run touches, in what order, call by
+call. The assembled driver is run on a 6502 emulator and its writes are compared against
+that statement. The oracle is the contract; the assembly is an implementation of it, and
+either one being wrong shows up as a difference rather than as a wrong sound.
+
+**A song is decoded forward, never indexed.** Reading a tick by multiplying its number
+bought exactly two things: skipping several ticks in one play call, and jumping to the loop
+point. The first is a matter of decoding several ticks in a row; the second the header can
+state outright. Giving both up in exchange for compression is what turns a program area
+that holds seconds into one that holds minutes.
+
+**The dictionary is the instrument table.** A song is built by playing samples at rows, so
+the shapes its planes repeat are knowable rather than discoverable: each sample offers the
+planes it writes, and every row playing it becomes a token naming that entry. Search fills
+what the samples leave uncovered.
+
+**Every layer earns its place on measured ground.** Each stage of the codec can be switched
+off on its own, and `make compression-report` writes what each one saves across a corpus of
+songs. The format's constants are settled from that report rather than from argument.
+
+## The song a file carries
+
+`Song` is the compressed song: the dictionary, one token stream per plane, the timer table,
+the clock and the loop point. The register values every channel writes are read back out of
+the streams on demand, so a trace, a writer and a test all speak to the compressed song
+without knowing it is one.
+
+A song is built three ways, and the difference between them is only where the ticks come
+from: `song_from_reconstruction` sounds a reconstruction's own instructions,
+`song_from_sample` sounds the slices of an export request, and `song_from_project` plays a
+whole arrangement out row by row through the same walk the sequencer sounds a song with.
+The last of those is the one that seeds the dictionary from the project's samples.
+
+A project carries no tuning of its own — each sample was reconstructed against one — so the
+samples state it by agreeing on it, and a project whose samples disagree is refused rather
+than sounded half in tune.
+
+## The codec
+
+The codec turns the four channels' per-tick register values into the token streams the
+driver reads, and back again. `compression/decode.py` is the golden model: every encoding is
+held against it, so what the console plays and what the encoder meant are the same values.
+The scheme itself, with the measurements each layer is settled on, is
+[song compression](../concepts/compression.md); what governs it here is where each part
+belongs and what holds it.
+
+**Planes.** A channel's registers for one tick sit adjacent, which is exactly the
+interleaving that destroys self-similarity — a volume envelope, a pitch line and a timbre
+are unrelated series braided together. Split apart, each is a slowly-changing series of its
+own.
+
+**A pitch index rather than a timer.** A tone channel's two timer bytes become one index
+into a table the block carries. It saves a byte a tick directly, but the reason it matters
+is that a timer cannot be transposed and an index can: the same figure played at several
+pitches is several copies in timer space and one entry plus a shift in index space.
+
+**Tokens.** A plane is written as holds, literals and phrase plays — the encoding is in
+[the format document](../formats/nsf.md#b4-the-token-streams). What matters here is that a
+token's count is a duration rather than a length, so one dictionary entry serves a figure
+however long it is held and whatever pitch it is played at.
+
+**The cheapest reading, not a greedy one.** A plane is parsed as a shortest path: every way
+of covering a tick is an edge priced in the bytes its token takes, and the cheapest path
+across the plane is its encoding. Costs are in the currency the program area is measured in,
+so the parse optimizes the thing that actually has to fit.
+
+**A phrase earns its entry.** Naming a phrase is not enough — an entry costs its own bytes.
+Each is weighed by what it spares the streams against a reading of the song that names no
+phrase at all, and the ones that fail to pay are dropped. Dropping them changes which ids
+are cheap, so the table settles over a few rounds.
+
+**Matching is measured once.** What a phrase plays against a plane at a position follows
+from the plane and the phrase alone, so it holds for every parse the encoder runs. Measuring
+it once per phrase, rather than once per parse, is what keeps the encoder's cost proportional
+to the dictionary rather than to the dictionary times the parses.
+
+A run reports what it holds as it goes and answers a caller that no longer wants it, in its
+own vocabulary; the export backend translates that into the stages a user sees. The walk
+through a project's song does the same, and it knows its length in advance, so it reads as a
+true fraction of the song.
+
+## The driver
+
+The driver is three sources: the entry points and the play call in `driver.s`, the clock in
+`clock.s`, and the plane decoders in `channels.s`.
+
+**The clock steps a tick at a time.** A play call adds the header's step to an accumulator
+and reads the whole ticks off the top; the driver then moves the clock on by one tick at a
+time, and each step answers what the channels are to do with it — play it, play it from the
+loop entry, or leave the console alone because the song has ended. Nothing wraps
+arithmetically: reaching the end either points the planes at where the song comes round or
+finishes.
+
+**One routine plays a tick on every plane.** A plane's state carries where its next token
+lies, where in a phrase body it stands, how much of that body is left, how much of the
+current token is left, the value it last played, and the shift it is playing at. The three
+kinds of token fold into that one shape — a hold is a phrase of no bytes, a literal is a
+phrase whose bytes lie inline behind its opcode — so playing a tick is the same handful of
+instructions whichever token is standing.
+
+**The plane's own state block is the whole of the dispatch.** Which plane is being advanced
+is a base offset held in `X`, the way a channel's register base is, so every plane's decoder
+is one routine called again. The state lives in zero page, well inside what the driver
+leaves free, and the linker configuration keeps the two-segment memory model an NSF loads.
+
+**The one sum the driver performs is the bend.** A tone channel's value plane resolves to a
+divider through the timer table, and its bend plane states the steps the tick stands away
+from it — sign-extended and added across both halves of the timer, with the high half
+reaching the register only where it changed. Everything that keeps the sum in range is
+settled in Python, so what crosses into assembly stays a byte moved and a carry followed.
+
+## How it is verified
+
+The chain runs from the register values upward, and each link is held on its own:
+
+| Level | How |
+|---|---|
+| The codec is lossless | every encoding decodes to the planes it was written from, over a corpus |
+| The codec is safe | a plane the codec finds nothing in stays within its literal bound |
+| The ratio | `make compression-report` — bytes per tick and ticks that fit, per layer |
+| The byte layout | a hand-built song serializes to expected bytes |
+| The assembly agrees with the specification | the include's equates are read and compared field by field |
+| The driver behaves | the assembled image on a 6502 emulator against `RegisterTrace.from_song`, over several rates and over songs that repeat |
+| The driver's arithmetic | a song stating a bend outright, held to the divider each tick is meant to sound at |
+| The audio | a captured trace re-rendered against the reconstruction's own approximation |
+| The whole export | a project exported, played on the emulator, and read back as the instructions the sequencer sounds |
+| Listening | `make nsf-samples` then `make nsf-render`, or any NSF player |
+| Speed | `make benchmarks` — the encoder's own cost on the shapes that scale worst |
+
+The audio comparison is the one that catches a mistake the trace would let through: the
+trace says the right registers were written, and the render says the result is the waveform
+the reconstruction was built as.
+
+## Building the driver
+
+`make player` assembles the sources with cc65 and writes `driver/binary/driver.bin`, which
+is committed beside them — exporting an `.nsf` needs no assembler, and the wheel carries the
+binary alone.
+
+The link line names our own configuration and our own object files, with the CPU stated
+outright. That is the guardrail that keeps the shipped image entirely ours: reaching for a
+cc65 target or library would place that project's start-up code and runtime in the bytes the
+package distributes. A build also holds the linker's own labels against the addresses the
+exporter states without one, so the committed image and the header describing it cannot
+drift apart.
+
+Installing cc65 is covered in [dependencies](dependencies.md).
diff --git a/docs/development/progress.md b/docs/development/progress.md
new file mode 100644
index 000000000..6d43652ce
--- /dev/null
+++ b/docs/development/progress.md
@@ -0,0 +1,180 @@
+# Progress
+
+This document governs how a long operation says how far it has come, and how that account reaches
+the reader watching it. Consult it when adding an operation that takes long enough to be watched,
+when changing what one reports, or when the report has to cross a process boundary.
+
+The subsystem spans three packages: the operations that report live in `sampletones_core` and
+`sampletones_player`, the line a report crosses between processes is
+`sampletones_core/parallelization/channel/`, and the layer that draws it is
+`sampletones_application/services/` and `logic/`. Layering between those packages is
+[`packages.md`](packages.md); the application's own layers are
+[`architecture.md`](architecture.md).
+
+---
+
+## Principles
+
+### 1. A run reports where it stands and hears whether it is still wanted
+
+Every long operation reports through one shape: a callable taking what the run has reached and
+answering whether the run goes on.
+
+```python
+ExportReporter = Callable[[ExportProgress], bool]
+```
+
+Each domain names its own progress type — `ExportProgress`, `WalkProgress`, `CodecProgress`,
+`ReconstructionProgress` — and its own `announce`, which builds that type, offers it, and raises
+`OperationCanceled` where the answer is no. One reporter therefore carries both directions: an
+operation is watched and withdrawn over the same line, and a caller that wants neither passes
+`silent_reporter` (`sampletones_shared/utils/progress.py`) and hears the run through to its end.
+
+### 2. A stage names the unit its counts are in
+
+A run passes through stages counting in units of their own — a song's ticks, a dictionary's bytes,
+a recording's frames, a batch's files. A report therefore names its stage, and what the counts mean
+is read from that name. `ExportStage` and `ReconstructionStage` are the two vocabularies today.
+
+Where the stages differ in what they cost, the enum states what each is worth against the others
+(`STAGE_WEIGHTS`), so one reading spans a run that changes what it is counting several times over.
+The weights are approximations measured over whole runs, justified by what they achieve: a bar that
+tracks the time a run actually takes. They are counts rather than fractions, and a reading divides
+them once at the point of use, which is what lets a finished run arrive exactly at its end.
+
+### 3. A report is filed as often as the run moves, and carried as often as it is worth reading
+
+An operation announces every step it makes — every frame, every row — because that is what it
+knows. Deciding how often that is worth passing on belongs to whoever carries it: `ReportRate`
+(`sampletones_shared/utils/progress.py`) spaces reports over `PROGRESS_STEPS` whatever the stage
+counts in, always takes a stage's first reading and its last, and treats a count that falls as a
+step the same way one that rises.
+
+Both carriers use it: `StageProgress` in the application services, and `JobReporter` in the
+conversion, which throttles on the worker side so the line between processes carries only what a
+bar can be redrawn at.
+
+### 4. A run reads as the items it finished plus the part of the one under way
+
+An operation counts the items it is measured in — files, samples, jobs. An item that reports its own
+progress makes the count alone a poor reading: a conversion of one recording stands at nothing out
+of one for its whole length. Both layers therefore carry the same pair:
+
+| Layer | Type | The counts | The work under way |
+|-------|------|-----------|--------------------|
+| Core | `TaskProgress` | `completed` / `total` | `steps`, one per running task |
+| Application | `ServiceProgress` | `completed` / `total` | `partial`, in items |
+
+and both derive `fraction` from them. The counts keep naming the items a reader recognizes — a
+status line still reads *Progress: 2/5 files* — while every bar in the application draws
+`fraction`, so one reading answers for a batch of files and for a single reconstruction alike.
+
+### 5. A task reaching another process reports over a channel
+
+Where an operation runs in a worker process, its reports reach the run over a `ProgressChannel`
+(`sampletones_core/parallelization/channel/`). The channel carries both directions of principle 1
+across the boundary, and callers depend on the Protocol rather than on any one way of crossing it.
+
+```python
+class ProgressChannel(Protocol):
+ def reporter(self, index: int) -> StepReporter: ...
+ def poll(self, timeout: float) -> Optional[TaskReport]: ...
+ def withdraw(self) -> None: ...
+ def close(self) -> None: ...
+```
+
+`ProcessProgressChannel` is the implementation. A manager stands beside the pool and owns both
+ends — a queue reports travel up and a flag a withdrawal travels down — under the same spawn
+context the pool's workers run in. Each end reaches a task as an ordinary value it is built with,
+so a worker started as a fresh interpreter reconnects to them on its own, which is what makes one
+channel serve every platform.
+
+---
+
+## Mechanics
+
+### The conversation a run has with its tasks
+
+```mermaid
+sequenceDiagram
+ participant REC as Reconstructor
+ participant JOB as JobReporter
+ participant CH as ProcessProgressChannel
+ participant PUMP as ProgressPump
+ participant RUN as TaskProcessor
+ participant SVC as ConversionService
+
+ Note over REC,JOB: worker process
+ REC->>JOB: announce(MATCHING, frame, frames)
+ JOB->>CH: TaskStep, where a step is due
+ CH-->>JOB: whether the run goes on
+ Note over CH,PUMP: process boundary
+ PUMP->>CH: poll, then drain what waits
+ PUMP->>RUN: TaskSteps.record + notify once
+ RUN->>SVC: TaskProgress(completed, total, steps)
+ SVC->>SVC: ServiceProgress(partial=…, current_item=ConversionItem)
+```
+
+A run's monitor thread waits on the results the pool hands back, so reading the channel belongs
+to a thread of its own: `ProgressPump`. It takes everything already waiting in one turn and
+announces once, which holds the announcements to the rate it reads at however many steps the tasks
+file in between.
+
+### Who owns what
+
+| Concern | Owner |
+|---------|-------|
+| The reporter shape, the silent one, and the spacing between reports | `sampletones_shared/utils/progress.py` |
+| A reconstruction's stages, their shares, and its own `announce` | `sampletones_core/reconstructions/{stage,progress}.py` |
+| Weighing a job's stage and carrying it to the run | `JobReporter` (`reconstructions/converter/progress.py`) |
+| The line between a run and its tasks | `sampletones_core/parallelization/channel/` |
+| Where the running tasks stand, and dropping a finished one | `TaskSteps` (`parallelization/steps.py`) |
+| Opening the channel, reading it, and reaping it | `TaskProcessor` (`parallelization/processor.py`) |
+| How long a run has left, from what it has covered | `ETAEstimator` (`parallelization/progress.py`) |
+| Turning a run's account into a result the application reads | `ConversionService` (`services/conversion/`) |
+| The bar, the status line, and the stage's name | `ConverterLogic` (`logic/main/converter.py`) |
+
+### A finished task stays finished
+
+A task's reports travel a line the run reads at its own pace, so one filed before its result
+arrived may be read after it. `TaskSteps` records the tasks the run has counted and lets their
+later reports go, which is what keeps a task's own progress and the run's completed count from
+describing the same work twice — and keeps the reading inside the run it describes.
+
+### The channel's lifetime
+
+The channel is a process of its own, opened on the first task that asks for a line and reaped once
+the run ends, whatever became of it. A run whose tasks report nothing never opens one.
+
+### Adding an operation that reports
+
+1. Give the domain a stage enum and a progress type, with an `announce` beside them, following
+ `sampletones_core/exports/progress.py`.
+2. Thread the reporter through the calls that know how far the work has come, and announce every
+ step they make.
+3. Where the work runs in this process, hand it a carrier that throttles and emits — `StageProgress`
+ for a service. Where it runs in a worker, ask `TaskProcessor._task_reporter` for a line and hand
+ the task a reporter built on it.
+4. Read the run through `fraction`, and name the stage from `LanguageManager` in the logic layer.
+
+---
+
+## Testing
+
+Progress is one path with two halves, and each is tested where it is cheap to test:
+
+| What | Where |
+|------|-------|
+| A stage's share, and a run read as a fraction | `tests/unit/sampletones_core/reconstructions/test_progress.py` |
+| The spacing between reports | `tests/unit/sampletones_shared/utils/test_progress.py` |
+| Where the running tasks stand, and a late report | `tests/unit/sampletones_core/parallelization/test_steps.py` |
+| A job's stage weighed and throttled | `tests/unit/sampletones_core/reconstructions/converter/test_progress.py` |
+| The real pipeline reporting its stages, in this process | `tests/integration/reconstruction/test_conversion_jobs.py` |
+| The line carrying steps and a withdrawal between real processes | `tests/integration/sampletones_core/parallelization/test_progress_channel.py` |
+| A conversion reporting itself end to end | `tests/integration/reconstruction/test_conversion_progress.py` |
+
+The two cross-process suites run real worker processes and stand something cheap in for the work —
+counting in one, a walk through the stages in the other — so what they measure is the wiring rather
+than a reconstruction. Both hold their worker at a chosen point until the test has taken the reading
+it is asserting on (`tests/suite/release.py`), so an assertion about work under way is made while
+that work is provably under way rather than resting on the scheduler.
diff --git a/docs/development/sequencer-blocks.md b/docs/development/sequencer-blocks.md
index 142b26523..48abe7fee 100644
--- a/docs/development/sequencer-blocks.md
+++ b/docs/development/sequencer-blocks.md
@@ -25,8 +25,8 @@ coordinates, which is what lets it land anywhere it is anchored.
Two axes underpin both grids:
-- **`constants/sequencer.py::CHANNEL_AXIS`** — `(None,) + GeneratorName.items()`. Index 0
- is the aggregate column (the tracker's **Sample**, the order's **Master**) and 1 to 4
+- **`constants/sequencer.py::CHANNEL_AXIS`** — `(None,) + ChannelName.items()`. Index 0
+ is the aggregate column (the tracker's **Voice**, the order's **Master**) and 1 to 4
are the channels. Both grids lay out along it, so a row index means the same thing in
either.
- **`view_model/sequencer/slot.py::TrackerSlot`** — a column paired with a subcolumn,
@@ -55,8 +55,8 @@ about reaches nothing.
A tracker block carries subcolumn offsets measured from `column_slot_base(column)`, and
every base is a multiple of the subcolumn count. An offset therefore addresses the same
-kind of subcolumn at whichever column it is replayed against: an instrument value cannot
-reach a volume slot. The paste hook takes a `TrackerCell` — a row and a column, with no
+kind of subcolumn at whichever column it is replayed against: a voice reference reaches only
+another voice slot. The paste hook takes a `TrackerCell` — a row and a column, with no
subcolumn — so the type states the rule: the anchor decides *where* a block lands and the
block decides *which kind* goes where.
@@ -75,7 +75,7 @@ Two consequences follow from the order the writes are taken in:
channel cell in the same block overwrites what the aggregate settled. The more specific
write wins.
- In the tracker, notes land before the transposes and volumes sharing their row, because
- placing a sample through the **Sample** column clears the channels of that row.
+ placing a sample through the **Voice** column clears the channels of that row.
## The order grows to what a paste reaches
@@ -122,7 +122,7 @@ SampleToNES/1 order rows=1 positions=0..1
The form and its reading live in `logic/sequencer/clipboard/`, which deals in blocks and
strings alone; the desktop's clipboard is reached through
-`utils/gui/clipboard.py::TextClipboard`, one more piece of external behaviour standing behind
+`utils/gui/clipboard.py::TextClipboard`, one more piece of external behavior standing behind
a protocol ([Architecture](architecture.md), principle 11). The sequencer coordinator wires
the two.
@@ -136,9 +136,9 @@ the span of slots or positions the block stands on, and a body whose lines or fi
with it states no block. The span also carries the alignment a tracker block needs, since the
first slot decides which subcolumn the block opens on.
-**A note names its sample by list position**, the figure the grid prints, so a block carried to
-another project plays whichever sample stands at that position there. A position the project's
-list falls short of reads as mixed, which is what the writer already makes of a sample it has
+**A note names its voice by list position**, the figure the grid prints, so a block carried to
+another project plays whichever voice stands at that position there. A position the project's
+list falls short of reads as mixed, which is what the writer already makes of a voice it has
nothing to place.
A field the form has no reading for refuses the whole text, so a parse answers with a block or
@@ -180,7 +180,7 @@ moment — the same predicate its key scope answers with, so the menu offers wha
press would reach — and the router asks the one that does to build its items into the menu
the bar has opened. It holds no state, resolving the surface on each call, so the menu
states the actions of whoever holds the cursor at the moment it is opened. The bar names the
-clipboard four greyed out when no grid answers, which is how a reader working from the menus
+clipboard four grayed out when no grid answers, which is how a reader working from the menus
learns the commands exist.
**`Del`** carries two meanings, resolved by whether a selection stands. Two ids cannot share
@@ -201,14 +201,14 @@ volume count separately, each carrying its own action.
## A shape selects to the grid's own edges
-`Ctrl+A` and its neighbours select a whole shape at once. Each shape is stated on the input
+`Ctrl+A` and its neighbors select a whole shape at once. Each shape is stated on the input
state as a run of bounds along one axis — slots in the tracker, rows in the order — handed to a
single builder that spans the other axis to the grid's full extent and lands the cursor on the
far corner. The whole frame, a column and a subcolumn are therefore three namings of one
rectangle, as the whole order and a channel row are of the other, and a grid laying out nothing
keeps the selection it had.
-The aggregate is an ordinary member of the axis here: selecting the **Sample** column selects a
+The aggregate is an ordinary member of the axis here: selecting the **Voice** column selects a
column the way selecting a channel does, and the **Master** row a row.
A press names its shape from the cell the cursor stands on, which is the cell the context menu's
@@ -222,7 +222,7 @@ Both grids compose one `TableSelection` (`ui/elements/table/selection.py`), whic
stands painted and the drag gesture that draws it. The grid states which of its cells the
selection covers, in its own coordinates; the repaint that follows reaches the cells whose
membership changed, marking each through the selectable's own selected state, which the
-table's theme colours. A rebuilt table asks for a reset, since the cells a selection stood on
+table's theme colors. A rebuilt table asks for a reset, since the cells a selection stood on
belong to the body that was replaced.
Both panels read the cell under a held pointer off their own geometry, because DearPyGui
@@ -268,9 +268,9 @@ Three rules make the travel feel like one gesture:
- **The selection stays put after a paste** rather than becoming the pasted footprint.
- **A note crosses a project by whichever route it took.** The in-app slot survives a project
close, because it must survive `on_project_replaced`, which fires on every undo, and it names
- its sample by id: a note whose sample the project in place lacks is left out of the write, and
+ its voice by id: a note whose voice the project in place lacks is left out of the write, and
the target keeps what it had. The clipboard's text names a list position instead, so the same
- note pasted through it plays whichever sample stands at that position. Transpose and volume
+ note pasted through it plays whichever voice stands at that position. Transpose and volume
are exact by either route.
- **A drag past the edge and the followed playhead both write the scroll.** With **Follow rows**
on during playback, `_reveal_playing_row` carries the sounding row to the head of the band
diff --git a/docs/development/undo.md b/docs/development/undo.md
index e056846be..21fc240fc 100644
--- a/docs/development/undo.md
+++ b/docs/development/undo.md
@@ -83,5 +83,13 @@ authoritative from YAML with no field defaults. The history panel renders a
window of `layout.sequencer.history.max_rendered_entries` rows around the
cursor and repaints rows in place via an index-keyed diff.
+**Strict checking is on where the code is written.** `deployment.yaml` carries the development
+values, so an edit path reaching the project outside a transaction raises
+`UntrackedMutationError` at once — in a development run and in the test suite alike, since
+several tests build the whole application and read that file. A user build takes the opposite
+values from `scripts/release_env_hook.py`, so a gap that reaches a release is healed into an
+`UNTRACKED` entry rather than shown to the user. The gap therefore surfaces where it can be
+fixed and stays quiet where it cannot.
+
Standalone reconstruction documents (a reconstruction loaded from disk that is not
a project sample) will gain their own history later, reusing the same engine.
diff --git a/docs/formats/bitphase.md b/docs/formats/bitphase.md
index e0693bb64..7a6ddc57a 100644
--- a/docs/formats/bitphase.md
+++ b/docs/formats/bitphase.md
@@ -78,11 +78,18 @@ carries every register value the channel takes for that tick. From
| `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 rests on the level that row carries — silence where the volume envelope ends on a
-note-off item, the channel's own level where the slice holds its volume. A sample's
-`loop` flag drives this, the same flag the FamiTracker exporter reads.
+which is the only mode there is. Bitphase reads every dimension out of one row, so the
+instrument returns to the earliest row any dimension repeats from and each dimension goes
+on sounding what it would have sounded. A slice whose dimensions all halt sets
+`loop = len - 1` and rests on the level that row carries — silence where the volume
+envelope ends on a note-off item, the channel's own level where the slice holds its
+volume.
+
+**A hand-written instrument's slices.** Bitphase bakes a channel's registers tick by
+tick, so an [instrument](../glossary.md#instrument) written by hand reaches a document
+as a slice per channel it sounds on, each reading the dimensions that channel offers
+and moving around the pitch it states. The envelopes are one set whatever the channel,
+so the slices differ only in what each channel reads of them.
**A held volume.** A slice whose volume envelope carries no item leaves its level to the
channel, so the exporter writes a full `volumeOrRate` for every frame the slice
@@ -94,9 +101,9 @@ silent row, the smallest instrument Bitphase plays.
**Equal lengths.** Instrument rows and table rows advance on independent per-tick
counters, so they share a length and a loop point 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 F).
+note sounds. The slice's longest dimension supplies that shared length, and every
+shorter one holds the value it ended on for the rest of it (`Envelope.resized`), which
+is what the sequences of a FamiTracker instrument each do on a counter of their own.
## C. Pitch
@@ -196,7 +203,7 @@ given, one per slice.
| Scope | `.btp` | `.json` preset |
| --- | --- | --- |
-| One generator slice | a playable document holding that instrument | one file |
+| One channel 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 | — |
@@ -227,7 +234,7 @@ you would see in the tracker either way.
## F. Bitphase capacity limits
-| Quantity | Bitphase limit | Exporter behaviour |
+| Quantity | Bitphase limit | Exporter behavior |
| --- | --- | --- |
| Items per instrument row list | unbounded | writes the envelope whole |
| Rows per table | unbounded | writes the contour, or the groove, whole |
diff --git a/docs/formats/configuration.md b/docs/formats/configuration.md
index e0b119bde..674da04ac 100644
--- a/docs/formats/configuration.md
+++ b/docs/formats/configuration.md
@@ -38,7 +38,8 @@ change any of these and a different library is selected or generated.
| `sample_rate` | audio sample rate in Hz | 8000–192000 |
| `spectrum_method` | how the spectrum is computed | `fft` / `logfft` / `cqt` |
| `transformation_gamma` | feature-space scaling (0 keeps the power spectrum, 100 is logarithmic) | 0–100 |
-| `a4_frequency`, `a4_pitch` | tuning reference: the frequency of A4 and its pitch number | — |
+| `a4_frequency` | tuning reference: what A4 sounds at, in Hz | 311 < value < 623 |
+| `a4_pitch` | tuning reference: the pitch number A4 names | 24–127 |
## `generation`
@@ -48,7 +49,7 @@ top-level keys and groups the scoring controls into `calculation`, `weights`,
| Key | Meaning | Values |
| --- | --- | --- |
-| `generators` | channels used | list of `pulse1`, `pulse2`, `triangle`, `noise` |
+| `channels` | channels used | list of `pulse1`, `pulse2`, `triangle`, `noise` (legacy key `generators` loads too) |
| `drive` | how hard the channels are pushed (alias: `mixer`) | 0 < value ≤ 5 |
| `reset_phase` | reset oscillator phase within each instruction | `true` / `false` |
| `final_regeneration` | re-render the chosen instructions at the end to keep oscillators continuous | `true` / `false` |
diff --git a/docs/formats/famitracker.md b/docs/formats/famitracker.md
index b528b94f5..afc41b613 100644
--- a/docs/formats/famitracker.md
+++ b/docs/formats/famitracker.md
@@ -1,9 +1,10 @@
# FamiTracker export format
-This document is the reference for how _SampleToNES_ writes FamiTracker files. It
-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.
+This document is the reference for how _SampleToNES_ writes and reads FamiTracker
+files. It describes the two binary formats the `sampletones_core.formats.famitracker`
+package produces — the `.fti` instrument file and the `.ftm` module file — states what
+a voice takes from an `.fti` it reads back, and lists the FamiTracker capacity limits
+that the project domain model will grow to respect.
The target is **vanilla FamiTracker 0.4.6** (`FILE_VER = 0x0440`). Files written to
this specification load in stock FamiTracker as well as the 0CC, Dn-FamiTracker and
@@ -127,8 +128,8 @@ The five sequence kinds, in slot order (`SequenceKind` in `specification/sequenc
| --- | --- | --- |
| 0 | Volume | output volume per tick, 0–15 |
| 1 | Arpeggio | semitone offsets added to the played note (absolute mode) |
-| 2 | Pitch | fine per-tick pitch bend, applied cumulatively |
-| 3 | Hi-pitch | coarse pitch bend |
+| 2 | Pitch | per-tick divider offset, one step per unit |
+| 3 | Hi-pitch | per-tick divider offset, sixteen steps per unit |
| 4 | Duty / Noise | pulse duty cycle 0–3, or the noise short/long mode |
Each sequence carries:
@@ -141,24 +142,45 @@ Each sequence carries:
- **setting** — the sequence mode; for arpeggio, `0` selects absolute (the offsets
are added to the played note).
-**Looping.** A looping instrument sets the loop point to `0` on every populated
-sequence, so its envelopes repeat from the start while the note is held; a one-shot
-instrument leaves every loop point at `-1`. A sample's `loop` flag drives this when
-the sample is exported into a module.
+**The bend and the arpeggio that pins it.** FamiTracker walks an instrument's sequences in
+slot order, and an arpeggio in absolute mode reloads the period from the note before the two bend
+sequences add to it (`CSeqInstHandler::ProcessSequence`). While the arpeggio runs, a pitch item is
+therefore an *offset from the note* for that tick, and a hi-pitch item is the same offset counted
+sixteen dividers at a time; once the arpeggio halts, the same items start accumulating on the
+running period instead. _SampleToNES_ writes and reads a bend as the per-tick offset, so the writer
+guarantees the arpeggio that makes that reading hold: an instrument writing a bend and no arpeggio
+gains one holding a single zero at loop point 0, and a shorter arpeggio is brought to the bend's
+length holding its final note. What one step is worth follows the note it bends — under a cent at
+the lowest notes, widening to a whole semitone at the highest, where the divider grid is already
+coarser than the note grid.
+
+**Looping.** Each sequence states the item it repeats from, so a held note sustains from
+that item on. A dimension written without one leaves its loop point at `-1` and plays its
+items once. Every envelope carries its own point, so a two-item duty cycle circles on its
+own period beside a longer volume envelope. A point beyond a sequence's own items repeats
+its final item, which is the value it would hold anyway.
**Lengths.** FamiTracker advances each sequence on its own per-tick counter. A sequence
that reaches its last item halts and leaves the value it wrote applied, which the driver
-holds for as long as the note sounds (`CSeqInstHandler::UpdateInstrument`). A one-shot
-instrument therefore carries every dimension at the length it was written: a two-item
-volume envelope beside a one-item duty envelope plays exactly as a padded pair would, and
-costs the padding less. A looping instrument brings its populated dimensions to the
-shortest length instead, so the envelopes repeat in step and the trailing zero that
-releases the note is dropped from the cycle.
-
-Every length stays within the 252 items a FamiTracker sequence holds, so a reconstruction
-longer than 252 frames — 8.4 s at the default 30 fps — exports its opening 252 frames and
-logs the shortening. The instruments panel colours a sequence input warning orange once it
-passes that length, so the limit is visible before an export.
+holds for as long as the note sounds (`CSeqInstHandler::UpdateInstrument`). Every
+dimension therefore carries the length it was written at: a two-item volume envelope
+beside a one-item duty envelope plays exactly as a padded pair would, and costs the
+padding less.
+
+**The release.** A volume envelope whose frames end audible carries one silent item past
+them, and that item is what stops the note: the driver holds a halted sequence's last value
+for as long as a row keeps the note sounding, so a volume envelope ending audible would sound
+to the end of the song. Every generator writes that item, so a volume dimension runs one item
+longer than the frames it describes.
+
+**The item limit.** A FamiTracker sequence holds 252 items, and that ceiling belongs to this
+writer: an envelope carries whatever length it was written at, and meets the limit only here.
+A dimension over it is written as its opening items, and a volume dimension keeps its release
+as the last of them — the note has to end, so the release displaces the sounding item that
+would not fit. A reconstruction therefore reaches the limit at 252 frames, since its volume
+carries the release past them; that is 8.4 s at the default 30 fps. The export reports what it
+left out, and the instruments panel colors a sequence input warning orange while a file would
+hold only part of it, so the limit is visible before an export.
An empty dimension is written as a disabled sequence, which is a different instrument from
one carrying a single zero: the disabled slot leaves that dimension to the channel, while a
@@ -166,7 +188,7 @@ one-item sequence sets the value once and holds it. A dimension arrives empty wh
reconstruction records it as one the channel governs — the state clearing the envelope in the
instruments panel puts it in (see [Reconstructions](reconstructions.md)).
-**How _SampleToNES_ fills an instrument.** Each generator slice of a sample's
+**How _SampleToNES_ fills an instrument.** Each channel slice of a sample's
reconstruction becomes one instrument, so a sample yields one to four instruments.
A reconstruction holds a stream for every channel, and one describing no frame is a
channel standing by (see [Reconstructions](reconstructions.md#contents)): it takes no
@@ -174,41 +196,90 @@ place in the instrument table, so the instruments an export writes are the chann
that play.
The arpeggio sequence carries the reconstruction's pitch contour as signed offsets,
and triggering the instrument at `initial_pitch` replays that contour. Volume, duty
-(or noise mode) and any pitch sequences carry across directly. The DPCM
-key-assignment table is empty by design.
-
-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
-
-FamiTracker bounds several quantities that the _SampleToNES_ `Project` currently
-leaves looser. The exporter guards these limits, so every file it writes loads: it
-raises on a project structure FamiTracker has no room for, and shortens an envelope
-that outruns a sequence. Enforcing them on the domain model — so the editor prevents
-reaching an unexportable state — is planned as a follow-up phase; this table is that
-checklist.
-
-| Quantity | FamiTracker limit | Project bound today | Exporter behaviour |
+(or noise mode) and the two bend sequences carry across directly; a conversion that bent no note
+records both bend dimensions as ones the channel governs, so they reach the file as disabled slots.
+The DPCM key-assignment table is empty by design.
+
+An [instrument](../glossary.md#instrument) written by hand is one set of envelopes
+every channel reads, which is the instrument model FamiTracker itself uses, so it
+becomes a single instrument however many channels play it. Each dimension is written at
+the length it was typed at, and each states its own loop point, so a tracker advancing
+every sequence on a counter of its own sounds it the way the engine here plays it. Every
+channel that names it reaches that one instrument, each against the initial pitch it
+reads — its note on the tonal channels, its period on noise.
+
+**Where a row's note comes from.** A voice states where its zero is and a row states
+the step from it, so a pattern cell holds `reference + transpose`, held inside the
+range a tonal channel plays and wrapped into the sixteen periods on noise. A sample's
+reference is the offset origin its conversion chose; a hand-written instrument's is the
+initial pitch it states.
+
+That 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. Reading an instrument file
+
+An `.fti` is read as well as written: **Import instrument...** in the sequencer brings
+one into the voice pool as a hand-written [instrument](../glossary.md#instrument).
+`instrument.py::read_fti` parses the layout in section A.1, and
+`voice.py::instrument_to_voice` makes a voice of the 2A03 instrument it holds.
+
+A voice carries all five dimensions — volume, arpeggio, pitch, hi-pitch and duty — each with
+the item it repeats from, so those come across as they stand. The voice takes the name the
+file states, and a file naming nothing leaves the voice named after the file itself. The
+arpeggio is read as offsets from the pitch a hand-written voice rests at, since a tracker
+instrument sounds at whatever note a row names it with.
+
+A sequence looping from one of its items gives that dimension the point; one halting at
+its end leaves the dimension playing its items once, holding the last of them for as long
+as the note sounds. A point outside the items the sequence carries is read as no point.
+
+**What the voice leaves to the file.** A tracker instrument states more than a voice
+holds, and each of those is reported once the import lands, so a reader learns what the
+file carried (`InstrumentOmission` in `voice.py`):
+
+| Stated in the file | What the voice holds |
+| --- | --- |
+| a bend outrunning its arpeggio | each item as the offset it states, where the tracker would accumulate it past the arpeggio's last tick (section B) |
+| a release point | a note the pattern cuts with a note-off |
+| an arpeggio in fixed, relative or scheme mode | absolute offsets |
+
+Each of these is a dimension the project model will grow to hold; `bugs-and-todos.md`
+under **Tracker** owns that list.
+
+A sequence carrying an item outside the range its dimension holds raises
+`InvalidInstrumentValuesError`. The file is read before the pool is touched, so a file
+the reader cannot take leaves the project as it stood and the history without an entry.
+
+## D. FamiTracker capacity limits
+
+FamiTracker bounds several quantities, and those bounds belong to this writer. A project
+holds what a reader wrote — an envelope of any length, a pool of any size — and meets a
+limit where a file is built, so the editor stays free of a format it may never export to.
+The writer guards each limit, so every file it writes loads: it raises on a project
+structure FamiTracker has no room for, and shortens an envelope that outruns a sequence
+while keeping the release that ends its note. What each limit costs is reported to the
+reader; this table is where those answers are stated.
+
+| Quantity | FamiTracker limit | Project bound today | Exporter behavior |
| --- | --- | --- | --- |
-| Instruments | 64 total | unbounded (1–4 per sample, so ≈16–64 samples) | raises when the distinct slices exceed 64 |
+| Instruments | 64 total | unbounded (1–4 per sample, one per hand-written instrument) | raises when the instruments exceed 64 |
| Sequences per kind | 128 | unbounded | raises when a kind's pool exceeds 128 |
-| Items per sequence | 252 | one item per reconstruction frame, unbounded | keeps the opening 252 items and logs a warning |
+| Items per sequence | 252 | one item per frame, plus the volume's release; unbounded | keeps the opening items, a volume dimension ending at its release, and reports what it left out |
| Patterns per channel | 128 (indices 0–127) | pool keyed by arbitrary ints | raises when a pattern index exceeds 127 |
| Order frames | 128 | unbounded | raises when the order exceeds 128 frames |
| Pattern length (rows) | 256 | 1–256 (`rows_per_pattern`) | matches; no guard needed |
-| Note range | C-0..B-7 (pitch 24–119) | `initial_pitch` 33–119 + `transpose` −24..+36 can exceed it | clamps to the nearest playable note (fidelity loss at the extremes) |
+| Note range | C-0..B-7 (pitch 24–119) | a reference of 33–119 plus a transpose reaching either end of that span | clamps to the nearest playable note (fidelity loss at the extremes) |
| Title / author | 32 bytes each | 64 characters | truncates to 32 bytes |
| Comment | free text (COMMENTS block) | 65536 characters | carried in full |
| Tempo / speed | engine-dependent (split at row `speed_split_point`) | tempo 32–255, speed 1–31 | written verbatim from settings |
-| DPCM samples | 64 | not modelled | always empty by design |
+| DPCM samples | 64 | not modeled | always empty by design |
The exporter also reserves a per-channel empty pattern index (`max used index + 1`)
for order slots the song leaves unset; a channel that already fills indices up to
@@ -216,7 +287,7 @@ for order slots the song leaves unset; a channel that already fills indices up t
order. When the domain model grows to enforce these limits, the editor can prevent
reaching a state the exporter would reject.
-## D. Driver memory footprint
+## E. Driver memory footprint
Compiling a module into an NSF lays each instrument out across two regions of the driver's
data, and an instrument's sequences size both of them. `footprint.py` measures the two, and
@@ -239,7 +310,8 @@ each instrument's body: a sequence-enable bitmask, then one pointer per populate
An instrument with `n` populated sequences carrying `s₁ … sₙ` items therefore occupies
`3 + 2n` bytes of the instrument region and `Σ (4 + sᵢ)` of the sequence region. A dimension the
channel leaves unused is written as a disabled slot, and the populated sequences alone are
-charged: `n` is 3 on the pulse and noise channels (volume, arpeggio, duty) and 2 on triangle.
+charged: a reconstruction that bent no note charges 3 sequences on the pulse and noise channels
+(volume, arpeggio, duty) and 2 on triangle, and each bend an instrument writes adds one more.
Each sequence is charged at its own length (section B), so shortening any one dimension shows
in the figure, and an instrument tops out at 777 bytes — three sequences at the 252-item limit.
@@ -257,8 +329,7 @@ chunk once. A per-instrument or per-sample figure states that instrument's own c
module total is therefore at most the sum of them. Within one instrument each kind appears
once, so its own sequences are charged once each.
-**Looping levels the sequences.** A looping instrument brings its populated dimensions to the
-shortest length, while a one-shot keeps each dimension as written (section B), so the two forms
-of one set of envelopes cost differently. A sample carries the flag that decides which applies;
-a reconstruction standing on its own is measured as a one-shot, matching the instrument its
-**Export instrument** writes.
+**Every dimension is charged at its own length.** A sequence is written at the length it holds
+(section B), so a figure counts each dimension as it stands and the loop point one of them
+carries adds a byte, not a padding. What a voice is measured at is therefore what its
+**Export instrument...** writes.
diff --git a/docs/formats/instruction-libraries.md b/docs/formats/instruction-libraries.md
index 6cc8eb725..bfa6fdd01 100644
--- a/docs/formats/instruction-libraries.md
+++ b/docs/formats/instruction-libraries.md
@@ -2,7 +2,7 @@
An instruction library is stored as a single `.ins` file holding, for every
possible instruction, the waveform its channel produces and that waveform's
-[spectrum](../glossary.md#spectrum-feature-histogram). It is the catalogue the
+[spectrum](../glossary.md#spectrum-feature-histogram). It is the catalog the
reconstruction search draws its candidates from. For what a library is and how
it is built, see [Instruction library](../concepts/instruction-library.md); this
page documents the file.
@@ -19,8 +19,8 @@ instruction.
Each entry contains:
-* **metadata** — the generator class (`pulse` / `triangle` / `noise`) and the
- instruction values below;
+* **metadata** — the generator class (`PulseGenerator` / `TriangleGenerator` /
+ `NoiseGenerator`) and the instruction values below;
* **instruction values** — the channel command:
* **on** (0–1) — whether the channel sounds;
* **pitch** (33–119) for pulse and triangle, or **period** (0–15) for noise;
diff --git a/docs/formats/nsf.md b/docs/formats/nsf.md
new file mode 100644
index 000000000..bc9ae9733
--- /dev/null
+++ b/docs/formats/nsf.md
@@ -0,0 +1,198 @@
+# NSF export format
+
+This document is the reference for the `.nsf` files _SampleToNES_ writes: the file a
+console or an NSF player loads, and the song block inside it that the player's own 6502
+driver reads. Read it before changing anything under `sampletones_player/nsf/`,
+`sampletones_player/compression/`, or the assembly under `sampletones_player/driver/`.
+The design behind the format — why a song is stored this way and how the driver is held
+to it — is in [the player](../development/player.md), and the compression scheme is explained
+in [song compression](../concepts/compression.md); the layout itself is here.
+
+An `.nsf` is unlike the tracker exports beside it. A [FamiTracker](famitracker.md) or
+[Bitphase](bitphase.md) file describes a song to a program that already knows how to play
+one; an `.nsf` carries its own player. The file therefore holds three things: a header
+naming where the program loads and which routines the console calls, the assembled driver,
+and the song that driver plays.
+
+Every constant named here has a counterpart under
+`sampletones_player/specification/`, and the assembly reads the same figures from
+`driver/assembly/include/song.inc`. The two are held against each other by a test, so a
+change made in one file and forgotten in the other is reported by name.
+
+## A. The file
+
+```
++0 NSF header, 128 bytes
++128 the assembled driver, loaded at $8000
++128 + driver length the song block, at the address the header states
+```
+
+The header is NSF version 1: the magic `NESM\x1a`, one song, the load, init and play
+addresses, three 32-byte text fields, and the NTSC play period. The text fields carry the
+name of what was exported, its author and the copyright. Written by `nsf/header.py`.
+
+The driver's entry points lead its image as a pair of jumps, so `init` answers at the load
+address and `play` three bytes later whatever the driver's own length. That is what lets
+the header state both addresses without assembling anything. The song follows the code
+directly, which is the one address a build decides — `driver/addresses.py` reads it back
+out of the linker's own labels.
+
+The console calls `init` once and then `play` once a video frame. The header asks for the
+NTSC frame period, so a player honoring the field and one driving from the frame itself
+run a song at the speed it was built at.
+
+**The program area is 32 KB**, from `$8000` upward, and the song block has whatever the
+driver leaves of it. A song that outgrows that space is reported as an export failure
+rather than written short.
+
+## B. The song block
+
+A song reaches the console as one **token stream** per plane, decoded a tick at a time
+against a **dictionary** of phrases and a **timer table** of pitches. Every channel writes
+a control plane and a value plane, and a tone channel writes a bend plane besides. Every
+offset below is a `uint16` counted from the block's own first byte, so the whole block
+plays from wherever the file loads it.
+
+```
++0 header
++55 timer table
+ phrase table: count, then one offset per phrase
+ phrase bodies: each a length byte, then its values
+ one token stream per plane, in plane order
+```
+
+### B.1 The header
+
+| Offset | Size | Field |
+|---|---|---|
+| +0 | 1 | ticks each play call advances by, whole part |
+| +1 | 2 | the same step's 16-bit fraction |
+| +3 | 2 | the ticks the song lasts |
+| +5 | 2 | the tick the song returns to, or `$FFFF` where it stops there |
+| +7 | 2 | where the timer table begins |
+| +9 | 2 | where the phrase table begins |
+| +11 | `PLANE_COUNT`×2 | where each plane's stream begins |
+| +33 | `PLANE_COUNT`×2 | where each plane's stream is re-entered once the song comes round |
+
+All fields are little-endian, and the header runs to `SONG_HEADER_SIZE` bytes.
+
+**The step is how one data set plays at every rate.** A reconstruction advances its
+envelopes at whatever rate it was built at, and the console calls `play` at the video
+frame rate. The step is the first measured against the second, held as a whole byte and a
+16-bit fraction; the driver adds it to an accumulator each call and advances the streams
+by the whole ticks that fall out. A song slower than the play rate stands still on the
+calls between its ticks, and a faster one advances several.
+
+### B.2 The timer table
+
+The table holds the timer register value every pitch sounds at: the low byte of each
+pitch, in pitch order, then the high byte of each. One pointer reaches both halves, which
+is what the driver's lookup takes advantage of.
+
+A plane names a pitch as its **index** — the distance above the lowest pitch the tuning
+covers — rather than as a divider. Pitches beyond the divider's range share the timer they
+clamp to, and the lowest pitch sounding a timer stands for the whole group.
+
+The table is written from the tuning the exported work was built at, computed by the very
+function the reconstruction's own generators render from.
+
+### B.3 The dictionary
+
+```
+count 1 byte, how many phrases the table holds
+offsets one uint16 per phrase, in id order
+bodies each phrase: a length byte, then its values
+```
+
+A **phrase** is a run of values a plane plays, stored at the pitch it was found at. Its
+position in the table is its **id**, and the ids that ride inside a token's opcode are the
+cheap ones, so the phrases a song leans on hardest are listed first.
+
+A song's phrases come from two places: the samples it plays, each offering the planes it
+writes, and a search over whatever those leave uncovered. Both are weighed the same way —
+a phrase keeps its entry by sparing the streams more bytes than the entry costs.
+
+### B.4 The token streams
+
+Each plane is a byte sequence written as tokens. The opcode's top two bits name the kind
+and the low six carry its operand:
+
+```
+00cccccc hold the value the plane reached, for c+1 ticks
+01nnnnnn b0..bn the n+1 bytes that follow, one per tick
+10pppppp cccccccc phrase p, for c+1 ticks
+11pppppp cccccccc tt phrase p, for c+1 ticks, every value plus tt
+```
+
+`p == $3F` escapes: the phrase's id is the byte that follows, which reaches every id in
+the table while the low ones stay a byte cheaper. The shift `tt` is **added within the
+byte**, wrapping — one addition on the 6502, and the same one the encoder agrees with.
+
+**A token's count is a duration, and it may run past the phrase.** Past its last value the
+plane holds that value onward, which is how a note whose envelope has finished keeps
+sounding; a count short of the body cuts the note off. One entry therefore serves every
+length a figure is played at, and — with the shift — every pitch.
+
+### B.5 Where a song comes round
+
+A song that repeats re-enters its streams partway through, so the tick it returns to
+begins a token on every plane, and that token names its values outright rather than
+leaning on the value the plane had reached. Coming round is then a matter of pointing each
+plane at the byte the header states and clearing what it was playing.
+
+## C. What the planes hold
+
+The planes are written in this order, and each group belongs to one channel:
+
+| Plane | Carries | Reaches |
+|---|---|---|
+| pulse 1 control | duty cycle and volume | `$4000` |
+| pulse 1 value | pitch index | `$4002`, `$4003` |
+| pulse 1 bend | divider offset | `$4002`, `$4003` |
+| pulse 2 control | duty cycle and volume | `$4004` |
+| pulse 2 value | pitch index | `$4006`, `$4007` |
+| pulse 2 bend | divider offset | `$4006`, `$4007` |
+| triangle control | linear counter | `$4008` |
+| triangle value | pitch index | `$400A`, `$400B` |
+| triangle bend | divider offset | `$400A`, `$400B` |
+| noise control | volume | `$400C` |
+| noise value | period and mode | `$400E` |
+
+Splitting a channel's registers apart is what gives each plane something to repeat: a
+volume envelope and a pitch line are separate series that turn over at their own rates.
+
+**The noise channel reads no bend.** It selects one of sixteen fixed periods, so there is
+no finer grid for a bend to reach, and the plane it would hold is left out of the block.
+
+**A timer's high half reaches the register only where it differs from the last one
+written.** Storing it restarts a pulse waveform and reloads the triangle's counter, so a
+channel holding one pitch across a rest keeps its phase running the way a rendered channel
+does.
+
+**A value plane names a pitch, not a divider.** Stating a note as its distance above the
+lowest one the song reaches is what lets `TRANSPOSED_PHRASE` move a whole phrase by adding
+to it, and a divider offset added to an index means nothing. A tone channel's **bend**
+plane is where the offset goes: one signed byte a tick, in two's complement, added to the
+divider the value plane's note resolves to.
+
+The driver sign-extends that byte and adds it across both halves of the timer, which is the
+only arithmetic it performs on a song's behalf. Everything that makes the sum land in
+range is settled in Python: the divider stays within `[MIN_TIMER, MAX_TIMER]`, so the
+timer's high half never exceeds three bits, never reaches the length-counter field beside
+them, and never collides with the `$FF` the driver marks an unwritten shadow by.
+
+## D. Limits
+
+| Limit | Value |
+|---|---|
+| Program area | 32 KB from `$8000`, less the driver |
+| Song length | as many ticks as the streams fit in |
+| Offsets within the block | `uint16` |
+| Ticks one hold or literal covers | up to `MAX_HOLD_TICKS` / `MAX_LITERAL_BYTES` |
+| Ticks one phrase token covers | up to `MAX_PHRASE_TICKS` |
+| Values one phrase holds | up to `MAX_PHRASE_LENGTH` |
+| Phrases one dictionary holds | up to `MAX_PHRASE_IDS` |
+
+A song reaching past the space behind the driver, or past what an offset field states,
+raises `SongTooLargeError` naming the part that overflowed. A project offering more
+phrases than a dictionary holds keeps the ones sparing the streams most, and says so.
diff --git a/docs/formats/projects.md b/docs/formats/projects.md
index 570d3275f..d11b4d613 100644
--- a/docs/formats/projects.md
+++ b/docs/formats/projects.md
@@ -1,10 +1,10 @@
# Projects
-A project gathers a set of reconstructions and arranges them into a song, saved
-as a single `.stp` file. It is what the sequencer works with, and what you hand
-over when you share a whole piece. See [Project](../concepts/project.md) for what a
-project is; this page documents the file. [Reconstructions](reconstructions.md)
-documents the individual samples it contains.
+A project gathers a set of voices and arranges them into a song, saved as a single
+`.stp` file. It is what the sequencer works with, and what you hand over when you
+share a whole piece. See [Project](../concepts/project.md) for what a project is;
+this page documents the file. [Reconstructions](reconstructions.md) documents the
+converted audio a sample stands on.
## Structure
@@ -16,7 +16,8 @@ A `.stp` file is a zip archive with two kinds of member:
id.
Keeping the reconstructions in separate members lets `project.json` stay small
-while the larger audio data travels alongside it in the same archive.
+while the larger audio data travels alongside it in the same archive. A
+[instrument](../glossary.md#instrument) carries no audio, so the document holds it whole.
### `project.json`
@@ -26,9 +27,23 @@ while the larger audio data travels alongside it in the same archive.
| `metadata` | the application name and version (managed automatically) |
| `info` | `title`, `author`, and `comment`, plus `created` and `modified` timestamps |
| `settings` | the engine settings: `nes_frequency`, `sample_rate`, `tempo`, `speed`, and the metric highlights `first_highlight` and `second_highlight` |
-| `samples` | the song's samples — each an `id`, a `name`, and the `reconstruction_id` of its audio member |
+| `voices` | the song's voices, each told apart by its `kind` (below) |
| `song` | the arrangement (below) |
+### `voices`
+
+Every voice carries an `id` and a `name`. The `kind` says what else it carries:
+
+| `kind` | Contents |
+| --- | --- |
+| `sample` | the `reconstruction_id` of its audio member |
+| `instrument` | its `envelopes` — `volume`, `arpeggio` and `duty_cycle` — and the `initial_pitch` and `initial_period` those values are measured against |
+
+Each envelope holds its `items`, one per tick, and the `loop_point` those items repeat
+from while a note is held, or `null` where they play once and the last item stands for as
+long as the note sounds. Every envelope states its own point, so a two-item duty cycle
+circles on its own period beside a longer volume envelope.
+
### `song`
The arrangement across the four channels:
@@ -36,8 +51,10 @@ The arrangement across the four channels:
* `rows_per_pattern` — the row count every pattern in the song shares;
* `order` — the arrangement itself: an ordered list of frames, each frame mapping
every channel to the pattern index it plays, or empty for a silent slot;
-* `channels` — per channel, a pool of patterns, each pattern a list of rows
- carrying the note, volume, and transpose data.
+* `channels` — per channel, a pool of patterns, each pattern a list of rows. A row
+ states the `command` its note column holds — the `voice_id` to start, or a
+ note-off — along with its `transpose` and `volume`. The channel a voice sounds on
+ is the one whose pool holds the row.
## Detached reconstructions
@@ -50,5 +67,13 @@ and no path that would only mean something on the author's machine.
`project.json` records the project format version it was written with. On load,
_SampleToNES_ requires that version to match the one it supports and declines an
-incompatible file rather than misreading it. Unknown or extra fields within a
-matching version are ignored, which leaves room for the format to grow.
+incompatible file rather than misreading it. A file written at a version the
+upgrade chain reaches is migrated in memory to the current shape before
+deserialization (see
+[Data compatibility](../development/compatibility.md)). Unknown or extra fields
+within a matching version are ignored, which leaves room for the format to grow.
+
+The current format version is 1.1. It gathers the pool under `voices`, each record
+stating its `kind`; names each channel pool by `name`; and names a row's note
+command by `voice_id` alone. Version 1.0 held the pool under `samples`, named a
+channel pool `generator`, and named a note command's channel beside its sample.
diff --git a/docs/formats/reconstructions.md b/docs/formats/reconstructions.md
index d91c4adb8..c0d463ad7 100644
--- a/docs/formats/reconstructions.md
+++ b/docs/formats/reconstructions.md
@@ -13,8 +13,10 @@ A `.stn` file holds:
* **metadata** — the application name and version, and the reconstruction
data-version used to check compatibility on load (see [Versioning](#versioning));
* **id** — a unique identifier for the reconstruction;
-* **source audio** — the path to the original recording, or empty when the
- reconstruction is [detached](#detached-reconstructions);
+* **source audio** — one path per source recording, in the order the stems setup
+ lists them: a single path for a conversion from one file, several for a stems
+ mix, and empty where the reconstruction is
+ [detached](#detached-reconstructions);
* **configuration** — a frozen snapshot of the
[generation configuration](../guide/configuration.md) used, so the file records
exactly how it was made: sample rate, NES frequency, enabled channels, spectrum
@@ -44,7 +46,12 @@ A `.stn` file holds:
is what says which of them the instrument itself writes; the rest are the
channel's, and the player keeps the value it already holds for them. A channel
in play writes them all as it is built, and clearing an envelope in the
- instruments panel adds that dimension here.
+ instruments panel adds that dimension here;
+* **stems assignment** — the stems setup the reconstruction was built under and,
+ per channel, the source holding each frame (`stems_data`). Every reconstruction
+ carries one: a conversion from a single file records one stem covering every
+ channel it plays. A frame no source took records the resting stem id, `-1`,
+ which is what a channel sounds where a channel cap or a hierarchy left it free.
A channel standing by rests at a reference pitch of its own, so the first envelope
written into it sounds on a mid-range note, and it leaves every dimension it offers
@@ -64,8 +71,19 @@ reconstruction stays self-contained and a saved project stays portable.
Each file records the reconstruction data-version it was written with. On load,
_SampleToNES_ requires that version to match the one it supports and declines a
-file written by an incompatible version rather than misreading it. The
-application version is stored alongside it, for reference.
+file written by an incompatible version rather than misreading it. A file
+written at a version the upgrade chain reaches is migrated in memory to the
+current shape before deserialization (see
+[Data compatibility](../development/compatibility.md)); the application version
+is stored alongside the data version, for reference.
+
+The current data version is 2.2. Version 2.2 renamed the per-channel stream and
+approximation keys from `generator_name` to `channel_name` and the channel
+selection under the embedded config from `generators` to `channels`; the enum
+values stored inside (`pulse1`, `pulse2`, `triangle`, `noise`) never changed. It
+also records the source audio as one path per stem and carries the `stems_data`
+record on every reconstruction; a file written before either existed is read with
+its single path listed and a one-stem record synthesized from what it plays.
## Storage and export
diff --git a/docs/glossary.md b/docs/glossary.md
index 8c53c90bd..3c7217875 100644
--- a/docs/glossary.md
+++ b/docs/glossary.md
@@ -11,10 +11,12 @@ The NES's sound chip (Ricoh 2A03). Its audio portion, the APU (Audio Processing
Unit), generates all of the console's sound. _SampleToNES_ emulates its four
melodic/percussive channels and no sampled-audio (DPCM) playback.
-### Channel / oscillator
+### Channel
-One of the 2A03's sound-producing units. There are four: two pulse, one
-triangle, one noise. The two words are used interchangeably here.
+One of the 2A03's four sound-producing units: `pulse1`, `pulse2`,
+`triangle`, and `noise`. The word *generator* names a distinct concept:
+the oscillator kinds an instruction library covers (`pulse`, `triangle`,
+`noise`) and the classes that implement them.
### Pulse (square)
@@ -84,7 +86,7 @@ frequency.
### Instruction library
-A precomputed catalogue holding, for every possible instruction, the waveform
+A precomputed catalog holding, for every possible instruction, the waveform
its channel produces and that waveform's spectrum. The search draws its
candidates from the library. Saved as an `.ins` file. See
[Instruction libraries](formats/instruction-libraries.md).
@@ -135,12 +137,12 @@ Perceptual weightings applied so each frequency bin counts in proportion to how
the ear hears it: ERB spaces bins by auditory critical bands, and K-weighting
applies a loudness curve.
-### Selector
+### Decoder
-The strategy that searches the library for each frame's instructions. The
-**greedy** selector treats every frame independently; the **Viterbi** selector
-(the default) favours continuity, changing a channel only when the gain in match
-quality outweighs the cost of the change.
+The strategy that reads a channel's per-frame candidates into the stream it plays,
+named by `generation.decoder.selector`. The **greedy** decoder plays each frame's
+best candidate; the **Viterbi** decoder (the default) favours continuity, changing a
+channel only when the gain in match quality outweighs the cost of the change.
### Calibration
@@ -177,6 +179,15 @@ reconstructed samples into a song.
In a FamiTracker instrument, a per-tick envelope for one dimension: volume,
arpeggio, pitch, hi-pitch, or duty/noise mode.
+### Bend
+
+How far a frame sounds from the note it names, counted in steps of the divider the
+channel loads. The **pitch** dimension counts one step per item and the **hi-pitch**
+dimension sixteen, and the two add up. What a step is worth follows the note: well under
+a cent at the lowest notes, widening to a whole semitone at the highest, where the
+divider grid is already coarser than the note grid. Only the pulse and triangle channels
+read a bend; the noise channel's sixteen periods have no finer grid.
+
### Pattern
A block of tracker rows spanning the channels. A song plays its patterns in an
@@ -193,7 +204,7 @@ The tracker tints the row that opens each, and the beat is what a tempo counts:
The engine ticks each row of a pattern lasts. An engine holds a row for a whole
number of ticks, so a tempo landing between two counts is played by varying the
-count from row to row, and the metre places the longer rows on the bar, then the
+count from row to row, and the meter places the longer rows on the bar, then the
beat, then inside the beat. Playback reads the groove by the row's position in the
pattern, so the pattern's first row starts it afresh.
@@ -216,26 +227,57 @@ tables, patterns, and order together.
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.
+### Voice
+
+Anything a tracker row can name: a **sample** or an **instrument**. A project holds its
+voices in one list, and a row states which one to start and the step it plays at.
+
### Sample (sequencer)
-A reconstruction added to the sequencer as a playable, placeable voice in the
-song.
+A reconstruction added to the sequencer as a playable voice, carrying the
+instruction stream its conversion found for each channel.
+
+### Sample column
+
+The tracker's leftmost data column. It places a sample across every channel that
+sample's reconstruction covers and clears the rest of the row, which is why it takes
+samples alone: an instrument sounds on the one channel that names it. It summarizes
+what those channels hold, reading `?` where they disagree. See
+[The sequencer](guide/sequencer.md#writing-a-pattern).
### Instrument
-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).
+One set of envelopes a channel reads while a note sounds, saved as an `.fti` file. A
+voice written by hand is a single instrument, placed on whichever channel suits it —
+the way a FamiTracker instrument is; a sample carries one instrument per channel it
+plays. See [The sequencer](guide/sequencer.md) and
+[FamiTracker export](formats/famitracker.md). Bitphase takes the same envelopes as a
+`.json` instrument preset. See [Bitphase export](formats/bitphase.md).
+
+### Initial pitch
+
+The value an instrument's frames are built at, and the note an exported preset is
+tuned to. An instrument written by hand states one for the tonal channels and a period
+for the noise channel, so the same envelopes sound on any of the four; the note it
+actually sounds at comes from the row that places it. The matching value on a sample is
+its per-channel [reference pitch](formats/reconstructions.md#contents).
+
+### Loop point
+
+The item a single envelope repeats from while a note is held, which lets an attack be
+followed by a sustained tail. Each envelope states its own, so a two-item duty cycle
+circles on its own period beside a longer volume envelope. An envelope without one
+holds its last item for as long as the note sounds.
## File types
| Extension | Contents |
| --- | --- |
-| `.ins` | [Instruction library](formats/instruction-libraries.md) — the candidate catalogue. |
+| `.ins` | [Instruction library](formats/instruction-libraries.md) — the candidate catalog. |
| `.stn` | [Reconstruction](formats/reconstructions.md) — a converted sample. |
| `.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)). |
+| `.nsf` | [NSF program](formats/nsf.md) — a song and the driver that plays it. |
| `.json` | Bitphase instrument preset ([export](formats/bitphase.md)), or the [configuration file](formats/configuration.md). |
diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md
index 5ed3630de..95ece9029 100644
--- a/docs/guide/configuration.md
+++ b/docs/guide/configuration.md
@@ -2,7 +2,7 @@
_SampleToNES_ reconstructs according to a **generation configuration** — the
sample rate, the NES frequency, which channels are used, how the audio is
-analysed, and how candidates are scored. The settings you reach for most often are
+analyzed, and how candidates are scored. The settings you reach for most often are
on the **Main** tab; the rest live in the configuration file, for when you want to
go deeper.
@@ -11,7 +11,7 @@ go deeper.
The **Main** tab exposes the everyday settings (grouped under **General
settings**, **Reconstructor settings**, and **Advanced settings**):
-- which **Generators** (channels) take part, and the **Drive** applied to them;
+- which **Channels** take part, and the **Drive** applied to them;
- **Normalize audio** and **Quantize audio** preprocessing;
- the **Sample rate** and **NES frequency**;
- the **Generation method** and **Feature scaling**, which set how the audio's
diff --git a/docs/guide/files.md b/docs/guide/files.md
index 06ec3d403..7c15b2c5b 100644
--- a/docs/guide/files.md
+++ b/docs/guide/files.md
@@ -28,27 +28,31 @@ You can point the library and output folders elsewhere from the **Main** tab's
| `.ftm` | FamiTracker module (exported) | wherever you choose |
| `.json` | Bitphase instrument preset (exported) | wherever you choose |
| `.btp` | Bitphase project (exported) | wherever you choose |
+| `.nsf` | NES sound file (exported) | wherever you choose |
The `.fti` and `.ftm` files are what you load into
-[FamiTracker](../formats/famitracker.md), and `.json` and `.btp` are what
-[Bitphase](../formats/bitphase.md) reads; the rest are _SampleToNES_'s own formats.
+[FamiTracker](../formats/famitracker.md), `.json` and `.btp` are what
+[Bitphase](../formats/bitphase.md) reads, and an `.nsf` plays on its own in a NES
+sound player or on the console; 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.
+The extension names what an export is written for: `.fti` and `.ftm` go to
+FamiTracker, `.json` and `.btp` to Bitphase, and `.nsf` to a NES sound player. 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 all three, so
+switching the type there switches the target; typing an extension yourself picks it
+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)` |
+| **Reconstruction ▸ Export instruments** | the batch | one file per channel beside that name, each named ` (channel)`, or a single file at that name for `.nsf` |
| **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.
+reconstruction uses, all in the folder you chose. An `.nsf` gathers every channel into
+one program, so it is the single file you named.
diff --git a/docs/guide/getting-started.md b/docs/guide/getting-started.md
index 342022fc3..b9b23eeb6 100644
--- a/docs/guide/getting-started.md
+++ b/docs/guide/getting-started.md
@@ -10,7 +10,7 @@ instruments, and building a whole song. Both assume it is already
2. In the **Filesystem** browser on the left, click an audio file (WAV, MP3,
FLAC, OGG, AIFF, or AU) — or a folder, to reconstruct every audio file inside it.
3. Optionally choose which channels to use under **Reconstructor settings** and
- adjust **General settings**. At least one generator must be enabled.
+ adjust **General settings**. At least one channel must be enabled.
4. Click **Convert sample** (or **Convert directory** for a folder). The first
time you use a given set of settings, the
[instruction library](../concepts/instruction-library.md) is built
@@ -35,11 +35,11 @@ tabs in full.
left, right-click a reconstruction and choose **Add to Sequencer**. If its NES
frequency differs from the project's, confirm with **Add anyway**.
4. In the **Tracker** grid, click a cell and type notes on your keyboard; assign a
- sample to a channel with the cell's right-click **Set instrument**.
+ sample to a channel with the cell's right-click **Set voice**.
5. Arrange the piece in the **Order** grid, and set **Rows**, **Tempo**, **Speed**,
and **NES frequency** under **Module options**.
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,
+The [sequencer guide](sequencer.md) covers the tracker grid, the order, voices,
and undo history in full.
diff --git a/docs/guide/interface.md b/docs/guide/interface.md
index 6ff99732e..e01a5d67d 100644
--- a/docs/guide/interface.md
+++ b/docs/guide/interface.md
@@ -1,143 +1,234 @@
# The interface
-_SampleToNES_ is a single window: a menu bar at the top, four tabs, and a status
-bar along the bottom. Within a tab, work generally flows left to right — you pick
-something on the left, act on it in the centre, and inspect or refine it on the
-right.
+_SampleToNES_ is one window: a menu bar at the top, four tabs, and a status bar
+at the bottom. Each tab works left to right — pick something on the left, set it
+up in the center, refine it on the right.
-This page covers the **Main**, **Instructions**, and **Reconstructions** tabs and
-the menus around them. The **Sequencer** has [its own page](sequencer.md).
+This page covers the **Main**, **Instructions**, and **Reconstructions** tabs
+and the menus around them. The **Sequencer** has [its own page](sequencer.md).
## Main
The **Main** tab turns an audio file into a
-[reconstruction](../concepts/reconstruction.md), and it is where most sessions
-begin.
+[reconstruction](../concepts/reconstruction.md). Most sessions start here.
Pick an audio file — or a whole folder — in the **Filesystem** browser on the
-left, set up how the reconstruction is done in the centre, and click **Convert
-sample** (or **Convert directory** for a folder). The browser opens the folders you
-were last working in, and **Collapse all** folds them away again. The
-[instruction library](../concepts/instruction-library.md) for your settings is
-built automatically the first time it is needed, so you can convert straight away.
-While it runs, the panel names the file going in and where the result is going, and
-clicking either path shows it in your file manager. When a single file finishes,
-**Load** opens the result on the **Reconstructions** tab; **Cancel** stops a run, and
-only one runs at a time.
+left, set up the conversion in the center, and click **Convert sample** (or
+**Convert directory** for a folder). The browser reopens the folders you were
+last working in, and **Collapse all** folds them away again. The [instruction
+library](../concepts/instruction-library.md) your settings need is built the
+first time you convert, so you can start straight away.
+
+While a run goes on, the panel shows the file going in and the file coming out;
+click either path to open it in your file manager. Afterwards, **Load** opens
+the new reconstruction on the **Reconstructions** tab — after a folder run the
+button reads **Open** instead. **Cancel** stops a run, and only one runs at a
+time.
+
+Converting one file or one stems mix always writes to the same filename. If a
+reconstruction of that name is already there, the app asks first; click
+**Convert anyway** to replace it. Converting a folder starts straight away: it
+converts the recordings that still need a reconstruction and leaves the ones
+already made, so you can rerun it to carry on where you stopped.
+
+### Stems mode
+
+**Stems mode** turns the card into a list of recordings to mix into one
+reconstruction. Tick it, then click each recording in the browser. You can also
+start from a classic conversion in one step: right-click a recording and choose
+**Add as stem**, or Ctrl-click it. Ctrl-clicking a folder offers everything
+inside it, as **Add folder as stems** does; if the folder holds more recordings
+than the list has room for, you pick which ones.
+
+Each row shows one recording and a checkbox per channel it may use. Untick them
+all and the row grays out: that recording sits out of the conversion, and stays
+in the list so you can bring it back.
+
+Rows sit in **level** bands. A level is a turn to choose: every recording on
+level 1 picks its channels before any on level 2, so a lead can take what it
+needs before a pad does. Drag a row onto another row to join that row's level,
+or into the gap between two levels to give it a level of its own. Right-clicking a
+row lists the same moves as menu items, alongside the recording's own actions —
+copy its name or path, or show the file in your file manager.
+
+**Order** sets how the levels take turns: round by round, or one level filled
+before the next picks. **x** takes a row out. Untick **Stems mode** and the
+first recording stays as your single selection.
+
+**Channels per source** caps how many channels one recording may hold in a
+single frame, and it applies to every conversion — one file, a whole folder, or
+a stems mix. Set to 1, each recording gets a single voice.
+
+Reconstructing a file or a folder from the browser converts that one thing, so
+while you are gathering stems it asks before dropping the list.
+
+### Settings
A few settings are worth knowing before you convert. Under **Reconstructor
-settings**, the **Generators** toggles choose which channels take part — at least
-one must be on — and **Drive** sets how hard they are pushed. **General settings**
-holds the analysis options: sample rate, NES frequency, generation method, and
-feature scaling. The rest, including the worker count and the output and library
-folders, sit under **Advanced settings**, which **View ▸ Show advanced settings**
-reveals. [Configuration](configuration.md) explains each one.
+settings**, the **Channels** toggles choose which channels take part — at least
+one must be on — and **Drive** sets how hard they are pushed. **General
+settings** holds the analysis options: sample rate, NES frequency, generation
+method, and feature scaling. The rest, including the worker count and the output
+and library folders, sit under **Advanced settings**, which **View ▸ Show
+advanced settings** reveals. [Configuration](configuration.md) explains each
+one.
## Reconstructions
-The **Reconstructions** tab is where you audition a reconstruction against the
+The **Reconstructions** tab is where you compare a reconstruction with the
original, fine-tune it, and export it.
-Open a saved reconstruction from the **Browser** on the left, which offers the
-same files two ways: **By configuration** groups them by the settings they were
-made with, and **By sample** gathers every version of one source audio together.
-If the current reconstruction 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.
+Open a saved reconstruction from the **Browser** on the left. It shows the same
+files two ways: **By configuration** groups them by the settings they were made
+with, and **By sample** gathers every version of one source audio together. If
+the reconstruction you have open has unsaved edits, you are asked whether to
+save it first. Play it back and switch **Play audio source:** between
+**Reconstruction** and **Original audio** to compare the two. **Locate original
+audio** re-links the source files if they have moved.
+
+### Finding your way around the browser
To keep the reconstructions you return to within reach, right-click one — or a
-whole folder — and choose **Mark as favorite**, which highlights it in both views.
-Tick **Favorites only** under the search box to narrow the browser to your
-favorites and everything inside them. The browser keeps the folders you had open
-while it narrows, so switching the tick on and off leaves the tree as you left it.
-If you would rather it opened its way down to each favorite for you, turn that on
-under **View ▸ Auto-expand favorites**, which answers for reconstructions and for
-folders separately. It opens the way down each time you tick **Favorites only**,
-and unticking folds those rows back.
-
-**Collapse all**, beside the refresh button, folds the whole tree away in one
-click. Whatever you leave open is remembered, so the tree comes back the way you
-left it the next time you start the application.
+whole folder — and choose **Mark as favorite**, which highlights it in both
+views. Tick **Favorites only** under the search box to narrow the browser to
+your favorites and everything inside them.
+
+Narrowing keeps whatever folders you had open, so ticking the box on and off
+leaves the tree as you left it. To have the browser open its way down to each
+favorite instead, turn on **View ▸ Auto-expand favorites**, which you can set
+for reconstructions and for folders separately. It expands each time you tick
+**Favorites only**, and unticking folds those rows back.
+
+**Collapse all**, beside the refresh button, folds the whole tree in one click.
+Whatever you leave open is remembered for the next time you start the app.
+
+### The Stems card
+
+A reconstruction mixed from several recordings has a **Stems** card. It lists
+each recording under the level it was picked on — the same list the converter
+showed you while you were gathering.
+
+Each row has a colored box for every channel that recording actually took, and
+a box at the front that moves all of them at once. Untick one and those frames
+go silent everywhere: in the waveform, in playback, in the original audio, and
+in a WAV export. That is how you hear what each recording contributed, channel
+by channel. A channel you have switched off under the waveform shows its column
+grayed, and your ticks stay where you put them.
+
+Click a row to show its recording in your file browser, and tick **Collapse
+levels** to read the whole list as one table. These ticks last for the session:
+saving records which recording owns which frame, not what you were listening to.
+
+**x** at the end of a row removes the recording from the reconstruction for
+good, so the app asks first. Its frames go silent and its row disappears, and
+the rest play as they did. One recording always stays, so the last row's **x**
+is grayed out.
+
+### Exporting
To get your results out, use the **Reconstruction** menu. **Export instruments ▸
FamiTracker instruments...** writes one `.fti` per channel, **Bitphase
-presets...** writes the same as `.json`, and **Export to WAV...** renders the
-audio. To use the reconstruction in a song, right-click it and choose **Add to
-Sequencer** (see the [sequencer guide](sequencer.md)).
+presets...** writes the same as `.json`, **NSF program...** writes a single
+`.nsf` that plays the whole reconstruction on a NES, and **Export to WAV...**
+renders the audio. To use the reconstruction in a song, right-click it and
+choose **Add to Sequencer** (see the [sequencer guide](sequencer.md)).
+
+### Editing instruments
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. Clearing a sequence hands that dimension to
-the channel, so an instrument with no volume sequence plays at whatever level its
-channel carries. Beside each channel is the room its instrument takes on the NES,
-with the whole sample's above them, so you can see what an edit costs. The figures
-are in bytes, and they count what a FamiTracker export saves, so clearing a
-sequence brings them down. **Export instrument...** writes the channel on show, for
-whichever tracker the save dialog's file type names — see [where your files
+instrument — its pitch, volume, arpeggio, and duty sequences — which you can
+edit by dragging the bars or typing values. Typing `|` before an item marks where
+that sequence repeats from while a note is held, so `15 14 | 12 10` attacks and then
+circles the last two values. Each sequence keeps its own point, so a short duty cycle
+can circle beside a longer volume envelope. Clearing a sequence hands that dimension
+back to the channel, so an instrument with its volume sequence cleared plays at
+whatever volume the channel is set to.
+
+Beside each channel is the room its instrument takes on the NES, with the whole
+sample's above them, so you can see what an edit costs. The figures are in bytes
+and count what a FamiTracker export saves, so clearing a sequence brings them
+down. **Export instrument...** writes the channel you are looking at, in
+whichever tracker format you pick in the save dialog — see [where your files
live](files.md#exported-files).
+An **instrument** — a voice you wrote by hand rather than converted, see the
+[sequencer guide](sequencer.md#voices-samples-and-instruments) — opens here too, from
+the **Voices** list's right-click ▸ **Edit**. It stands on no recording, so the tab
+shows its envelopes alone: one set every channel reads, under the instrument's own
+name. A row of the tracker states the note it sounds at, so the pitch steppers stand
+down and **Audition** takes their place: pick **Pulse**, **Triangle** or **Noise**, and
+the note keys — `Z` to `M` for one octave and `Q` to `U` for the one above it, at the
+octave the tracker types in — play the instrument on that generator. The waveform card
+draws what you would hear, in that generator's own color, and redraws as you edit.
+Editing an instrument puts away whatever reconstruction the tab held.
+
## Instructions
-The **Instructions** tab generates and browses the
-[instruction library](../concepts/instruction-library.md) for your current
-settings, and lets you inspect individual instructions.
+The **Instructions** tab builds and browses the [instruction
+library](../concepts/instruction-library.md) for your current settings, and lets
+you inspect single instructions.
+
+You will rarely come here just to build a library — converting on the **Main**
+or **Reconstructions** tab builds the matching one for you. It is useful for
+building one ahead of time, or for exploring what a configuration can produce:
+pick an instruction and its waveform and spectrum appear with a player, so you
+can hear a single NES tone on its own.
-You will rarely come here just to build a library — reconstructing on the **Main**
-or **Reconstructions** tab builds the matching one automatically. It earns its
-place for building one ahead of time, or for exploring what a configuration can
-produce: pick an instruction and its waveform and spectrum appear with a player,
-so you can hear a single NES tone on its own. Click **Generate library** to build
-the library for the current settings (if one already exists, _SampleToNES_ asks
-**Regenerate library?**), **Cancel generation** to stop, and **Refresh
-instructions data** to re-read the catalogue; selecting an entry in the
-**Libraries** tree loads it.
+**Generate library** builds the library for the current settings; if one already
+exists, _SampleToNES_ asks **Regenerate library?** first. **Cancel generation**
+stops it, **Refresh instructions data** re-reads the catalog, and selecting an
+entry in the **Libraries** tree loads it.
## Around the app
The menu bar and status bar sit outside the tabs.
-Each menu covers one kind of work: **File** for projects, **Edit** for undo, redo,
-and what you can do where your cursor stands, **Reconstruction** for the current
-reconstruction and its exports, **Playback** for playing and for muting the
-sequencer's channels, **View** for settings and the window, and **Help** for
-**About**. What **Edit** offers below undo and redo follows your cursor: the block
-actions of the sequencer grid you are in, or the actions of the sample you have
-picked in the **Samples** list.
-
-Two items write audio you can play anywhere: **Reconstruction ▸ Export to WAV...**
-for the reconstruction on show, and **File ▸ Render song...** (`Ctrl+Shift+E`) for
-the sequencer's whole song, as a WAV or an MP3 —
-[rendering to audio](sequencer.md#rendering-to-audio) covers the options it offers.
-
-Two other items are easy to miss. **View ▸ Show advanced settings** reveals the extra
-options on the **Main** tab. **Playback ▸ Audio settings...** picks the playback
-device, sample rate, and buffer size; these change what you hear, while the
-**Sample rate** and **NES frequency** on the **Main** tab change how audio is
-reconstructed.
-
-`F1` to `F4` bring up the four tabs in order — **Main**, **Reconstruction**,
-**Sequencer**, and **Instructions** — and work while you are typing, so any tab is
-one key away.
-
-`1` to `4` toggle the four NES channels on the tab in front of you: the
-generators on **Main**, the channels drawn on **Reconstructions**, and the song's
-mix on the **Sequencer**. In the sequencer's grids the digits type values into the
-cell you are on, so use the channel names or the **Playback ▸ Channels** menu to
-mute there.
+Each menu covers one kind of work: **File** for projects, **Edit** for undo,
+redo, and whatever your cursor is on, **Reconstruction** for the current
+reconstruction and its exports, **Voice** for the ways a voice comes into the
+sequencer and what the one you picked offers, **Playback** for playing and for
+muting the sequencer's channels, **View** for settings and the window, and **Help**
+for **About**. What **Edit** offers below undo and redo follows your cursor: the
+block actions of the sequencer grid you are in, or the actions of the voice you
+have picked in the **Voices** list.
+
+**Voice** answers "how do I get a voice in" on its own: **New instrument**, **Add
+sample from file...**, **Import instrument...**, and **Add to Sequencer** stand
+together at the top, and the actions of the voice you picked follow — the same set the
+list's own right-click menu prints. See [voices](sequencer.md#voices-samples-and-instruments).
+
+Two items write audio you can play anywhere: **Reconstruction ▸ Export to
+WAV...** for the reconstruction you have open, and **File ▸ Render song...**
+(`Ctrl+Shift+E`) for the sequencer's whole song, as a WAV or an MP3 — [rendering
+to audio](sequencer.md#rendering-to-audio) covers its options.
+
+Two other items are easy to miss. **View ▸ Show advanced settings** reveals the
+extra options on the **Main** tab. **Playback ▸ Audio settings...** picks the
+playback device, sample rate, and buffer size; these change what you hear, while
+the **Sample rate** and **NES frequency** on the **Main** tab change how audio
+is reconstructed.
+
+`F1` to `F4` switch tabs in order — **Main**, **Reconstructions**,
+**Sequencer**, and **Instructions**. They work while you are typing, so any tab
+is one key away.
+
+`1` to `4` toggle the four NES channels on the tab in front of you: the channels
+that take part on **Main**, the channels drawn on **Reconstructions**, and the
+song's mix anywhere else. In the sequencer's grids the digits type values into
+the cell you are on, so mute there with the channel names or the **Playback ▸
+Channels** menu.
### Keyboard shortcuts
**View ▸ Keyboard shortcuts...** (`Ctrl+K`) lists everything you can do from the
keyboard and lets you change any of it. Click an action's shortcut and press the
-keys you want, or type them into the box below the list. If another action already
-uses those keys, the app names it and asks whether to hand them over. **Reset to
-defaults** puts everything back, and your changes take effect when you press
-**OK**.
+keys you want, or type them into the box below the list. If another action
+already uses those keys, the app tells you which one and asks whether to hand
+them over. **Reset to defaults** puts everything back, and your changes take
+effect when you press **OK**.
On macOS the shortcuts use Command where other platforms use Control. What you
change is saved with your settings and is there the next time you start.
-Project properties belong to a project and are covered in the
-[sequencer guide](sequencer.md).
+Project properties belong to a project and are covered in the [sequencer
+guide](sequencer.md).
diff --git a/docs/guide/sequencer.md b/docs/guide/sequencer.md
index 1f47a3fdd..601abd5d6 100644
--- a/docs/guide/sequencer.md
+++ b/docs/guide/sequencer.md
@@ -1,40 +1,100 @@
# The sequencer
-The **Sequencer** tab is a tracker: it arranges reconstructions into a song across
-the four NES channels and exports it as a FamiTracker
+The **Sequencer** tab is a tracker: it arranges voices into a song across the four
+NES channels and exports it as a FamiTracker
[module](../formats/famitracker.md) (`.ftm`). It works on a
[project](../formats/projects.md), so start one with **File ▸ New project** (or
-open an existing `.stp`). The pattern grid and order sit in the centre, a browser
-for pulling in reconstructions on the left, and the module settings, sample list,
+open an existing `.stp`). The pattern grid and order sit in the center, a browser
+for pulling in reconstructions on the left, and the module settings, voice list,
and undo history on the right.
-## Adding samples
-
-A song is built from **samples** — reconstructions imported as playable
-instruments. Add one from the **Reconstructions** browser on the left (right-click
-▸ **Add to Sequencer**), or with **Add to Sequencer** on the **Reconstructions**
-tab. If a reconstruction was made at a different NES frequency than the project and
-the project already has samples, _SampleToNES_ warns with **Different NES
-frequency**; **Add anyway** adds it regardless.
-
-Manage the imported samples in the **Samples** list on the right: right-click one
-to **Edit**, **Rename**, **Duplicate**, **Remove**, or reorder it, and toggle its
-**Loop** flag. The **Edit** menu carries the same actions for the sample you have
-picked. The right-click menu also names how much room the sample takes on the NES —
-its total, then each channel it plays — measured as its **Loop** flag has it. The
-figures are in bytes, and they count what a FamiTracker export saves.
-Removing a sample that patterns still use asks **Remove sample** first, because it
+## Voices: samples and instruments
+
+A song is built from **voices**, and there are two kinds. A **sample** is a
+reconstruction brought in as something a row can play. An **instrument** is written by
+hand — envelopes with no recording behind them — for the melodies and basses you write
+yourself. Both sit in the **Voices** list on the right, numbered together, and a
+mark at the front of each row says which kind it is. The mark carries a color as well
+as a shape — amber for a sample, magenta for an instrument — and the same two colors
+name a voice in the pattern grid and in the history, so the two kinds read apart
+wherever one is named.
+
+Four ways bring a voice in, and the **Voice** menu holds all four:
+
+| Way in | What arrives |
+|--------|--------------|
+| **New instrument** | An instrument holding a note at full volume, ready to place and hear |
+| **Add sample from file...** | A reconstruction saved anywhere on disk, as a sample |
+| **Import instrument...** | A FamiTracker instrument file (`.fti`), as an instrument |
+| **Add to Sequencer** | The reconstruction the **Reconstructions** tab holds, as a sample |
+
+The first three also sit at the top of the **Voices** list, and on the list's own
+menu — right-click below the rows to reach it. **Add to Sequencer** is on the
+**Reconstructions** browser to the left (right-click a reconstruction) and on the
+**Reconstructions** tab. If a reconstruction was made at a different NES frequency
+than the project and the project already has voices, _SampleToNES_ warns with
+**Different NES frequency**; **Add anyway** adds it regardless.
+
+A new instrument starts out holding a note at full volume, so you can place it and
+hear it straight away; give it the sound you want on the **Reconstructions** tab
+(right-click ▸ **Edit**). See [editing instruments](interface.md#editing-instruments).
+An imported `.fti` arrives with the volume, arpeggio, and duty-cycle envelopes the
+file states, and **Instrument imported** names anything the file held on a tracker's
+own terms that the voice leaves behind — see [reading an instrument
+file](../formats/famitracker.md#c-reading-an-instrument-file).
+
+**New instrument from ▸ _channel_** on a sample's menu writes what one of its channels
+plays into an instrument of its own, so a recorded part becomes envelopes you edit by
+hand.
+
+Right-click any voice to **Edit**, **Rename**, **Duplicate**, **Remove**, or reorder it.
+**Export instrument...** writes the voice out as a `.fti` another tracker reads — a
+sample holds one instrument per channel it plays, so it asks which. The **Edit** menu
+carries the same actions for the voice you have picked. The right-click menu also names
+how much room the voice takes on the NES — a sample's total and then each channel it
+plays, and an instrument's single figure. The figures are in bytes, and they count what a
+FamiTracker export saves. Removing a voice that patterns still use asks first, because it
clears every row that references it.
+Hovering a row says what that voice is in one line: its name, whether it is a sample or
+an instrument, the channels a sample plays, and the room it takes.
+
## Writing a pattern
The **Tracker** grid is the pattern editor. Each row is one step in time; the
columns are the **Sample** and the four channels — **Pulse 1**, **Pulse 2**,
-**Triangle**, **Noise** — each carrying a note, volume, and transpose. Click a cell
-and type on your keyboard to enter a note, piano-style. Right-clicking a cell opens
-the rest of the operations — **Set instrument**, **Note off**, **Clear cell** and
-**Clear row**, transpose and volume adjustments, **Play from here** to audition from
-the cursor row, and **Play from this frame** to start at the top of the shown frame.
+**Triangle**, **Noise** — each carrying a voice, a pitch, and a volume. Click a cell
+and type its value. Right-clicking a cell opens the rest of the operations — **Set
+voice**, **Note off**, **Clear cell** and **Clear row**, transpose and volume
+adjustments, **Play from here** to audition from the cursor row, and **Play from
+this frame** to start at the top of the shown frame.
+
+The **Sample** column places a sample across every channel its reconstruction
+covers, and clears the rest of the row. It takes samples alone: an instrument sounds
+on the one channel that names it, so **Set voice** lists instruments there grayed out,
+and a number typed over one leaves the cell reading what it held. Name an instrument
+in the channel column you want it on.
+
+A cell holding a voice wears that voice's color — amber for a sample, magenta for an
+instrument — while an empty cell, a cut, and a `?` where the **Sample** column's
+channels disagree read in a plain gray. Silencing a channel dims its cells and keeps
+those colors, so a muted column stays as readable as the rest.
+
+## Reading and typing a pitch
+
+A pitch cell holds one number, and it reads in the terms of the voice the channel is
+carrying. A sample was converted at a pitch of its own, so its cells read as steps
+from it — `+00` plays it as recorded, `+0C` an octave up. An instrument sounds at
+whatever note a row names it with, so its cells read as the notes they sound — `C-4`,
+`A#3`.
+A row that only bends a note reads the same way as the row that started it.
+
+Type a note into an instrument's cell piano-style: the bottom two rows of the keyboard are
+one octave (`Z` `S` `X` `D` `C` …) and the two above them the next (`Q` `2` `W` `3`
+`E` …). **Octave** above the grid says where the bottom row opens. The keys work on
+a sample's cell too, writing the step that reaches the note you pressed. The noise
+channel selects one of sixteen periods rather than a note, so its cells are typed as
+a signed value.
## Arranging the song
@@ -96,7 +156,7 @@ A copy also goes to your desktop's clipboard as plain text, so a block carries b
two open windows of _SampleToNES_ — copy in one, paste in the other — and you can paste
one into a message to show someone what you wrote. Anything else on the clipboard
leaves you with the last block you copied here. Notes travel by their number in the
-**Samples** list, so a block pasted into another project plays whichever sample holds
+**Voices** list, so a block pasted into another project plays whichever voice holds
that number there.
## Transposing and shading
@@ -154,7 +214,7 @@ the song runs.
## Listening to one channel at a time
Channel names are switches. Click **Triangle** at the top of the tracker to silence
-that channel: its name greys, its column and its row in the **Order** grid go
+that channel: its name grays, its column and its row in the **Order** grid go
neutral, and its notes dim — still readable, still editable, just not sounding.
Click the name again to bring it back. The same click works on the channel's name
in the **Order** grid, and both grids show every change, so a channel looks the same
@@ -182,22 +242,22 @@ audible.
Set the song's timing in **Module options** on the right: **Rows** per pattern,
**Tempo**, **Speed**, and the **NES frequency**. Changing the **NES frequency**
-after samples exist re-times how they all play back, so it asks **Change NES
+after voices exist re-times how they all play back, so it asks **Change NES
frequency** first (with a **Don't ask again** option).
The project's title, author, and comment — which carry into the exported module —
are set in **Project properties**, from the button or **File ▸ Project
-properties...**, along with the metre the song is counted in.
+properties...**, along with the meter the song is counted in.
-**First highlight** and **Second highlight** are that metre: how many rows make a
+**First highlight** and **Second highlight** are that meter: how many rows make a
beat, and how many make a bar. The tracker tints the row that opens each one. The
bar divided by the beat is how many beats you hear in a bar, so the default 4 and
16 give four beats of four rows — common time. Waltz time keeps the four-row beat
and shortens the bar to 12, for three beats. The beat is what the tempo counts, so
the two together say how fast the song is felt as well as how it looks.
-The metre also places the song's timing. Most tempos ask for a row length the engine
-can only reach on average, so the rows of a bar differ a little: the metre gives the
+The meter also places the song's timing. Most tempos ask for a row length the engine
+can only reach on average, so the rows of a bar differ a little: the meter gives the
extra time to the row that opens the bar, then to the row that opens each beat, which
keeps the beat audible where you expect it.
@@ -212,6 +272,14 @@ FamiTracker module...**) writes the `.ftm`. See
[FamiTracker export](../formats/famitracker.md) for what the module contains and
the limits it respects.
+**File ▸ Export ▸ NSF program...** writes the song as an `.nsf` instead: a program
+the console itself plays, carrying its own player, so it needs no tracker to sound.
+The console holds one program in 32 KB, so a long song can outgrow it — the export
+says so rather than writing a file that plays part of itself. See
+[NSF export](../formats/nsf.md) for what the file holds, and
+[song compression](../concepts/compression.md) for how a song of minutes is fitted
+into that space.
+
## Rendering to audio
A module is for a tracker. To get a file anyone can play, use **File ▸ Render
diff --git a/docs/index.md b/docs/index.md
index 1b02957d0..0052d4d1a 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -2,8 +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)
-or [Bitphase](glossary.md#bitphase).
+the results into a song and export them to [FamiTracker](glossary.md#famitracker),
+to [Bitphase](glossary.md#bitphase), or as an `.nsf` program the console itself plays.
This is the documentation for using it, understanding how it works, and building
on it.
@@ -19,7 +19,7 @@ The [**guide**](guide/) walks through the application from installation onward.
- [Installation](guide/installation.md) — the standalone build, running from source, and GPU acceleration.
- [Getting started](guide/getting-started.md) — your first reconstruction and your first song.
- [The interface](guide/interface.md) — the Main, Reconstructions, and Instructions tabs, and the menus.
-- [The sequencer](guide/sequencer.md) — the tracker: arranging samples into a song, exporting a module, and rendering it to audio.
+- [The sequencer](guide/sequencer.md) — the tracker: arranging samples and hand-written instruments into a song, exporting a module, and rendering it to audio.
- [Command line](guide/command-line.md) — running without the graphical interface.
- [Where your files live](guide/files.md) — the folders and file types _SampleToNES_ uses.
- [Configuration](guide/configuration.md) — the settings you can change, and where.
@@ -30,7 +30,9 @@ The [**concepts**](concepts/) section explains the ideas behind the
reconstruction. It is written to be read without the source code.
- [Reconstruction algorithms](concepts/reconstruction.md) — how a sample becomes a stream of NES instructions.
-- [Instruction library](concepts/instruction-library.md) — the catalogue of NES sounds the search draws from.
+- [Stems reconstruction](concepts/stems.md) — how one reconstruction is assigned across several stems.
+- [Instruction library](concepts/instruction-library.md) — the catalog of NES sounds the search draws from.
+- [Song compression](concepts/compression.md) — how a whole song is fitted into the space an NES program has for it.
- [Project](concepts/project.md) — a whole composition: a song and the reconstructions it is built from.
- [Calibration](concepts/calibration.md) — how the reconstruction's settings are tuned by experiment.
@@ -38,11 +40,12 @@ reconstruction. It is written to be read without the source code.
The [**formats**](formats/) section documents the files _SampleToNES_ reads and writes.
-- [Instruction libraries](formats/instruction-libraries.md) — the `.ins` candidate catalogue.
+- [Instruction libraries](formats/instruction-libraries.md) — the `.ins` candidate catalog.
- [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.
+- [NSF export](formats/nsf.md) — the `.nsf` program the console plays, and the song block inside it.
- [Configuration file](formats/configuration.md) — the `config.json` structure.
## Programming with SampleToNES
@@ -55,15 +58,17 @@ worked examples.
The [**development**](development/) section is for contributors.
- [Architecture](development/architecture.md) — the application's layers and the contracts between them.
+- [Package layers](development/packages.md) — the packages the repository divides into, and the order they import each other in.
- [Undo engine](development/undo.md) — the design of the undo/redo subsystem.
- [Sequencer blocks](development/sequencer-blocks.md) — the rules copy, cut, paste and delete follow on both grids.
- [Playback](development/playback.md) — the audio transport shared by every view, and rendering the song to a file.
+- [Progress](development/progress.md) — how a long operation says how far it has come, in one process and across the pool's workers.
+- [Console player](development/player.md) — the 6502 driver an `.nsf` carries, the codec that fits a song beside it, and how both are verified.
- [Reconstruction browser](development/browser.md) — how a reconstructions directory becomes the tree both browser tabs render, and what narrows it.
- [Configuration](development/config-organization.md) — how the YAML configuration package is laid out.
- [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 8947f2d0e..20448235e 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -82,6 +82,7 @@ dev = [
"isort==8.0.1",
"mypy==2.1.0",
"pre-commit==4.6.0",
+ "py65==1.2.0",
"pylint==4.0.6",
"pylint-pydantic==0.4.1",
"pytest==9.1.1",
@@ -98,12 +99,17 @@ build-backend = "hatchling.build"
conflicts = [[{ extra = "gpu" }, { extra = "gpu-cuda11" }]]
[tool.hatch.build.targets.wheel]
+exclude = [
+ "src/sampletones_player/driver/assembler",
+ "src/sampletones_player/driver/assembly",
+]
packages = [
"src/sampletones",
"src/sampletones_application",
"src/sampletones_assets",
"src/sampletones_config",
"src/sampletones_core",
+ "src/sampletones_player",
"src/sampletones_shared",
"src/sampletones_synthesis",
]
@@ -121,33 +127,38 @@ known_first_party = [
"sampletones_assets",
"sampletones_config",
"sampletones_core",
+ "sampletones_player",
"sampletones_shared",
"sampletones_synthesis",
]
[tool.pytest.ini_options]
addopts = "--import-mode=importlib"
+pythonpath = ["."]
[tool.coverage.run]
source = [
"sampletones_application",
"sampletones_assets",
"sampletones_core",
+ "sampletones_player",
"sampletones_shared",
"sampletones_synthesis",
]
[tool.coverage.report]
show_missing = true
-fail_under = 80
+fail_under = 90
[tool.mypy]
python_version = "3.12"
+mypy_path = "$MYPY_CONFIG_FILE_DIR/stubs"
files = [
"src/sampletones",
"src/sampletones_application",
"src/sampletones_assets",
"src/sampletones_core",
+ "src/sampletones_player",
"src/sampletones_shared",
"src/sampletones_synthesis",
]
@@ -160,6 +171,14 @@ strict = true
fail-under = 9.9
ignore-paths = "^tests/.*$"
load-plugins = ["pylint_pydantic"]
+fail-on = [
+ "fatal",
+ # "fixme",
+ "redefined-outer-name",
+ "used-before-assignment",
+ "unused-import",
+ "unused-variable",
+]
[tool.pylint.messages_control]
disable = [
diff --git a/scripts/calibration.py b/scripts/calibration.py
index c129d1800..4bf8d57d0 100644
--- a/scripts/calibration.py
+++ b/scripts/calibration.py
@@ -11,8 +11,8 @@
from sampletones_core.calibration.runner import build_variants, evaluate_variants
from sampletones_core.configs import Config
from sampletones_core.constants.enums import (
- DEFAULT_GENERATORS,
- GeneratorName,
+ DEFAULT_CHANNELS,
+ ChannelName,
SpectrumMethod,
)
from sampletones_shared.logger import logger
@@ -21,7 +21,7 @@
DEFAULT_OUTPUT_ROOT: Final[Path] = USER_PATH_DOCUMENTS / "calibration"
DEFAULT_METHODS: Final[str] = f"{SpectrumMethod.FFT.value},{SpectrumMethod.CQT.value}"
DEFAULT_PERCEPTUAL_EXPONENTS: Final[str] = "1.0"
-DEFAULT_GENERATOR_NAMES: Final[str] = ",".join(generator.value for generator in DEFAULT_GENERATORS)
+DEFAULT_CHANNEL_NAMES: Final[str] = ",".join(generator.value for generator in DEFAULT_CHANNELS)
def main() -> None:
@@ -59,22 +59,22 @@ def main() -> None:
help="Comma-separated values of weights.temporal_loss_weight; empty keeps the base blend.",
)
parser.add_argument(
- "--generators",
+ "--channels",
type=str,
- default=DEFAULT_GENERATOR_NAMES,
- help="Comma-separated channel generators every variant reconstructs with.",
+ default=DEFAULT_CHANNEL_NAMES,
+ help="Comma-separated channels every variant reconstructs with.",
)
arguments = parser.parse_args()
- generators = [GeneratorName(name.strip()) for name in arguments.generators.split(",") if name.strip()]
- if not generators:
- parser.error("--generators requires at least one generator name")
+ channels = [ChannelName(name.strip()) for name in arguments.channels.split(",") if name.strip()]
+ if not channels:
+ parser.error("--channels requires at least one channel name")
base = Config.load(arguments.config) if arguments.config else Config.default()
base = base.model_copy(
update={
"generation": base.generation.model_copy(
- update={"generators": generators},
+ update={"channels": channels},
)
},
)
@@ -94,7 +94,7 @@ def main() -> None:
referees = build_referees(sample_rate)
variants = build_variants(base, methods, exponents, temporal_weights)
- channel_names = ", ".join(generator.value for generator in generators)
+ channel_names = ", ".join(channel.value for channel in channels)
logger.info(
f"Evaluating {len(variants)} variants x {len(items)} items x {len(referees)} referees on {channel_names}"
)
diff --git a/scripts/checks/import_boundary.py b/scripts/checks/import_boundary.py
index 14c61f15a..c906623b0 100755
--- a/scripts/checks/import_boundary.py
+++ b/scripts/checks/import_boundary.py
@@ -1,17 +1,27 @@
#!/usr/bin/env python3
"""
-Enforces layer-boundary import rules across the sampletones_application package.
+Enforces the import boundaries the source tree is layered by.
-Each import rule names a file glob, the import prefixes forbidden there, and the
-contract modules exempt from those prefixes: a layer may consume another
-layer's data contract (e.g. a service's result types) while its implementation
-modules stay out of reach. The script checks every Python source file matched
-by the glob and reports any import that begins with a forbidden prefix.
+A layer graph names a tree of modules, the units it divides into, and the units each one may
+import; every other unit is out of reach, so an edge across the graph is declared before it is
+taken. The packages under `src/` are one such graph — `sampletones_core` sits below
+`sampletones_player`, which is what keeps the reconstruction engine clear of the console player —
+and the player's own subpackages are another, where `driver/assembler/` reaches the cc65 toolchain
+and stays outside the wheel, so no shipped module imports it.
-Token rules additionally forbid a regex within a file glob, enforcing contracts
-a prefix cannot express — e.g. that panels never compose a column suffix
-(`SUF_PANEL_*`) or parent into another panel's container.
+Boundary rules state a contract the other way round, by the import prefixes a directory stays clear
+of, and carry the contract modules exempt from those prefixes: a layer may consume another layer's
+data contract (e.g. a service's result types) while its implementation modules stay out of reach.
+`sampletones_application` is layered that way.
+
+Token rules additionally forbid a regex within a file glob, enforcing contracts a prefix cannot
+express — e.g. that panels never compose a column suffix (`SUF_PANEL_*`) or parent into another
+panel's container.
+
+`sampletones_config/boundaries/` declares what the boundaries are and
+`sampletones_shared/meta/import_boundary/` holds how they are read and reported; this script runs
+them over a source tree and prints what they find.
Usage:
python scripts/checks/import_boundary.py [files...] # check specific files
@@ -19,261 +29,19 @@
"""
import argparse
-import re
import sys
from pathlib import Path
-from typing import Final, List, NamedTuple, Optional, Sequence, Set
-
-from sampletones_shared.meta.source.modules import source_paths
-from sampletones_shared.meta.source.packages import package_directory
-
-APP_ROOT: Final[Path] = package_directory("sampletones_application")
-
-IMPORT_RE = re.compile(r"^\s*(import|from)\s+([\w.]+)")
-
-VISUAL = [
- "dearpygui",
- "sampletones_application.ui",
- "sampletones_application.utils.gui",
-]
-
-SERVICE_CONTRACTS = [
- "sampletones_application.services.result",
- "sampletones_application.services.render.result",
- "sampletones_application.services.song_player.result",
-]
-
-
-class BoundaryRule(NamedTuple):
- pattern: str
- forbidden: List[str]
- contracts: List[str] = []
-
-
-class TokenRule(NamedTuple):
- pattern: str
- forbidden: str
- message: str
-
-
-class Violation(NamedTuple):
- """One import or token a rule forbids, and where a reader opens it."""
-
- kind: str
- location: str
-
-
-RULES: List[BoundaryRule] = [
- BoundaryRule(
- "config/**/*.py",
- [
- *VISUAL,
- "sampletones_application.coordinators",
- "sampletones_application.application",
- ],
- ),
- BoundaryRule(
- "logic/**/*.py",
- [
- *VISUAL,
- "sampletones_application.coordinators",
- "sampletones_application.services",
- ],
- contracts=SERVICE_CONTRACTS,
- ),
- BoundaryRule(
- "view_model/**/*.py",
- [
- *VISUAL,
- "sampletones_application.coordinators",
- "sampletones_application.config",
- "sampletones_application.logic",
- "sampletones_application.services",
- ],
- ),
- BoundaryRule(
- "services/**/*.py",
- [
- *VISUAL,
- "sampletones_application.view_model",
- "sampletones_application.coordinators",
- "sampletones_application.config",
- "sampletones_application.logic",
- ],
- ),
- BoundaryRule(
- "shell.py",
- [
- "sampletones_application.logic",
- "sampletones_application.services",
- ],
- ),
- BoundaryRule(
- "coordinators/**/*.py",
- [
- "sampletones_application.application",
- "sampletones_application.shell",
- ],
- ),
- BoundaryRule(
- "ui/**/*.py",
- [
- "sampletones_application.coordinators",
- "sampletones_application.logic",
- "sampletones_application.services",
- "sampletones_application.config",
- "sampletones_application.application",
- "sampletones_application.shell",
- "sampletones_application.utils.gui.dialogs",
- ],
- ),
-]
-
-
-TOKEN_RULES: List[TokenRule] = [
- TokenRule(
- "ui/panels/**/*.py",
- r"\bSUF_PANEL_",
- "ui/panels must not reference a column suffix (SUF_PANEL_*); a panel receives its "
- "parent through create_panel(parent), set by the coordinator that owns the layout",
- ),
- TokenRule(
- "ui/panels/**/*.py",
- r"parent\s*=\s*TAG_SEQUENCER_TRACKER_PANEL\b",
- "ui/panels must not parent into another panel's container (TAG_SEQUENCER_TRACKER_PANEL); "
- "the coordinator injects the parent through create_panel(parent)",
- ),
- TokenRule(
- "ui/panels/**/*.py",
- r"\bTAG_GLOBAL_THEME_PANEL_(SURFACE|GROUND)\b",
- "ui/panels must not bind a structural depth theme (TAG_GLOBAL_THEME_PANEL_SURFACE/"
- "GROUND); only the layout primitives own depth (TabColumns binds the column, card() "
- "binds the card), and a panel binds only semantic themes",
- ),
-]
-
-
-def _matches_prefix(module: str, prefix: str) -> bool:
- return module == prefix or module.startswith(prefix + ".")
-
+from typing import List, Sequence
-def find_token_violations(
- filepath: Path,
- rule: TokenRule,
-) -> List[Violation]:
- pattern = re.compile(rule.forbidden)
- violations: List[Violation] = []
- for line_number, line in enumerate(
- filepath.read_text(encoding="utf-8").splitlines(),
- start=1,
- ):
- if pattern.search(line):
- location = f"{filepath}:{line_number}"
- violations.append(
- Violation(
- kind=rule.message,
- location=f"{location}: {line.strip()}",
- )
- )
-
- return violations
-
-
-def find_violations(
- filepath: Path,
- rule: BoundaryRule,
-) -> List[Violation]:
- violations: List[Violation] = []
- for line_number, line in enumerate(
- filepath.read_text(encoding="utf-8").splitlines(),
- start=1,
- ):
- match = IMPORT_RE.match(line)
- if match is None:
- continue
-
- module = match.group(2)
- if any(_matches_prefix(module, contract) for contract in rule.contracts):
- continue
-
- for prefix in rule.forbidden:
- if _matches_prefix(module, prefix):
- location = f"{filepath}:{line_number}"
- violations.append(
- Violation(
- kind=prefix,
- location=f"{location}: {line.strip()}",
- )
- )
- break
-
- return violations
-
-
-def rule_modules(
- package: Path,
- pattern: str,
- swept: Set[Path],
- selection: Optional[Set[Path]],
-) -> List[Path]:
- """The modules a rule reaches, in path order.
-
- A rule names its files by one glob whether the check runs over the whole package or over the
- files a hook lists, so the two entry points read the same rule the same way.
-
- Args:
- package: Package the rule globs are written against.
- pattern: Glob the rule names its files by.
- swept: Visible modules the package holds, which the glob is held to.
- selection: Resolved paths to narrow the rule to, or `None` to reach every module it names.
-
- Returns:
- List[Path]: The modules the rule applies to.
- """
- matched = {path.resolve() for path in package.glob(pattern)} & swept
- if selection is not None:
- matched &= selection
-
- return sorted(matched)
-
-
-def check_boundaries(package: Path, selection: Optional[Set[Path]]) -> List[Violation]:
- """Every import and token the rules forbid in the package.
-
- The package is swept first, so the rules run over the modules it holds and a root reading as
- empty stops the check where it would otherwise report a clean tree.
-
- Args:
- package: Package the rule globs are written against.
- selection: Resolved paths to narrow the check to, or `None` to check the whole package.
-
- Returns:
- List[Violation]: What the rules report, boundary rules first.
-
- Raises:
- NotADirectoryError: If the package names no directory.
- FileNotFoundError: If the package holds no module to read.
- """
- swept = {path.resolve() for path in source_paths([package])}
- violations = [
- violation
- for rule in RULES
- for filepath in rule_modules(package, rule.pattern, swept, selection)
- for violation in find_violations(filepath, rule)
- ]
- violations.extend(
- violation
- for token_rule in TOKEN_RULES
- for filepath in rule_modules(package, token_rule.pattern, swept, selection)
- for violation in find_token_violations(filepath, token_rule)
- )
- return violations
+from sampletones_shared.meta.import_boundary.check import check_boundaries
+from sampletones_shared.meta.import_boundary.configs.rules import ImportBoundaryRules
+from sampletones_shared.paths.source import SOURCE_ROOT
def main(argv: Sequence[str]) -> int:
"""Report every import and token the layer boundaries forbid."""
parser = argparse.ArgumentParser(
- description="Check layer-boundary import rules across the application package.",
+ description="Check the import boundaries the source tree is layered by.",
)
parser.add_argument(
"files",
@@ -284,19 +52,25 @@ def main(argv: Sequence[str]) -> int:
parser.add_argument(
"--all",
action="store_true",
- help=f"check every module under {APP_ROOT.name}/ instead of named files",
+ help=f"check every module under {SOURCE_ROOT.name}/ instead of named files",
)
parser.add_argument(
- "--package",
+ "--source",
type=Path,
- default=APP_ROOT,
- help="package the rule globs are written against",
+ default=SOURCE_ROOT,
+ help="source root the rule roots are named within",
)
arguments = parser.parse_args(list(argv))
files: List[Path] = arguments.files
selection = None if arguments.all else {path.resolve() for path in files}
- violations = check_boundaries(arguments.package, selection)
+ boundaries = ImportBoundaryRules.load()
+ violations = check_boundaries(
+ arguments.source,
+ boundaries.boundary_rules(),
+ boundaries.tokens,
+ selection,
+ )
if not violations:
return 0
diff --git a/scripts/checks/palette_colors.py b/scripts/checks/palette_colors.py
index 002c9bf84..c3fa9e4cb 100755
--- a/scripts/checks/palette_colors.py
+++ b/scripts/checks/palette_colors.py
@@ -1,12 +1,12 @@
#!/usr/bin/env python3
"""
-Checks that a colour stays a palette token until the moment it is drawn with.
+Checks that a color stays a palette token until the moment it is drawn with.
`BaseColor.rgba` answers with the palette active right now, so a consumer that holds the
token follows a palette swap and one that stores the answer keeps the shade it read at
construction. The check reports the three ways that contract is lost: an attribute assigned the
-resolved value, a theme colour filled outside the palette bindings that record it, and a colour
+resolved value, a theme color filled outside the palette bindings that record it, and a color
written into the shipped configuration as a literal instead of a palette token.
Usage:
@@ -45,7 +45,7 @@
class ColorFinding(NamedTuple):
- """One place a colour stops following the palette, and what to do about it."""
+ """One place a color stops following the palette, and what to do about it."""
location: str
message: str
@@ -73,7 +73,7 @@ def _resolves_a_color(value: ast.expr) -> bool:
def stored_colors(module: SourceModule) -> Iterator[ColorFinding]:
- """Every attribute a module assigns the resolved value of a palette colour.
+ """Every attribute a module assigns the resolved value of a palette color.
Args:
module: Module to read.
@@ -91,13 +91,13 @@ def stored_colors(module: SourceModule) -> Iterator[ColorFinding]:
location=module.location(statement),
message=(
f"stores .{COLOR_PROPERTY}; hold the BaseColor and read "
- f".{COLOR_PROPERTY} where the colour reaches DearPyGui"
+ f".{COLOR_PROPERTY} where the color reaches DearPyGui"
),
)
def dpg_module_helper() -> Tuple[Path, str]:
- """The module allowed to fill a theme colour, and the helper every other module calls.
+ """The module allowed to fill a theme color, and the helper every other module calls.
Returns:
Tuple[Path, str]: The resolved path of the bindings module, and the helper's name.
@@ -113,7 +113,7 @@ def unregistered_theme_colors(
bindings_module: Path,
theme_color_helper: str,
) -> Iterator[ColorFinding]:
- """Every theme colour a module fills without recording the token behind it.
+ """Every theme color a module fills without recording the token behind it.
Args:
module: Module to read.
@@ -121,7 +121,7 @@ def unregistered_theme_colors(
theme_color_helper: Name of the helper a report points at.
Yields:
- ColorFinding: One per call, naming the theme colour that stays at the shade it was
+ ColorFinding: One per call, naming the theme color that stays at the shade it was
built with.
"""
if module.path.resolve() == bindings_module:
@@ -131,12 +131,12 @@ def unregistered_theme_colors(
if isinstance(node, ast.Call) and terminal_name(node.func) == THEME_COLOR_CALL:
yield ColorFinding(
location=module.location(node),
- message=f"fills a theme colour directly; call {theme_color_helper} so a swap repaints it",
+ message=f"fills a theme color directly; call {theme_color_helper} so a swap repaints it",
)
def literal_colors(path: Path) -> Iterator[ColorFinding]:
- """Every hex colour a shipped configuration file writes out in place of a palette token.
+ """Every hex color a shipped configuration file writes out in place of a palette token.
Args:
path: Configuration file to read.
@@ -151,7 +151,7 @@ def literal_colors(path: Path) -> Iterator[ColorFinding]:
for match in HEX_COLOR.finditer(line):
yield ColorFinding(
location=f"{path}:{number}",
- message=f"writes the colour {match.group()} directly; name a palette token instead",
+ message=f"writes the color {match.group()} directly; name a palette token instead",
)
@@ -176,11 +176,11 @@ def find_detached_colors(
def find_literal_colors(package: Path, palettes: Path) -> List[ColorFinding]:
- """Every hex colour the shipped configuration writes out, outside the palettes that carry values.
+ """Every hex color the shipped configuration writes out, outside the palettes that carry values.
Args:
package: Configuration package to sweep.
- palettes: Directory holding the palettes, where a colour value belongs.
+ palettes: Directory holding the palettes, where a color value belongs.
Returns:
List[ColorFinding]: One finding per literal, in file order.
@@ -196,31 +196,31 @@ def find_literal_colors(package: Path, palettes: Path) -> List[ColorFinding]:
def main(argv: Sequence[str]) -> int:
- """Report every colour the application stores resolved or the configuration writes out."""
+ """Report every color the application stores resolved or the configuration writes out."""
logger.set_level(level=logging.ERROR)
bindings_module, theme_color_helper = dpg_module_helper()
parser = argparse.ArgumentParser(
- description="Check that a colour stays a palette token until it is drawn with.",
+ description="Check that a color stays a palette token until it is drawn with.",
)
parser.add_argument(
"--package",
type=Path,
default=APPLICATION_PACKAGE,
- help="package whose colour reads to check",
+ help="package whose color reads to check",
)
parser.add_argument(
"--config",
type=Path,
default=CONFIG_DIRECTORY,
- help="shipped configuration package whose colours must name palette tokens",
+ help="shipped configuration package whose colors must name palette tokens",
)
parser.add_argument(
"--palettes",
type=Path,
default=PALETTES_DIRECTORY,
- help="directory holding the palettes, where colour values belong",
+ help="directory holding the palettes, where color values belong",
)
arguments = parser.parse_args(list(argv))
@@ -240,14 +240,14 @@ def main(argv: Sequence[str]) -> int:
return 0
print(
- "Colour(s) that stop following the active palette:",
+ "Color(s) that stop following the active palette:",
file=sys.stderr,
)
for location, message in findings:
print(f" {location}: {message}", file=sys.stderr)
print(
- f"\nFound {len(findings)} colour(s) detached from the palette.",
+ f"\nFound {len(findings)} color(s) detached from the palette.",
file=sys.stderr,
)
return 1
diff --git a/scripts/checks/shortcut_actions.py b/scripts/checks/shortcut_actions.py
new file mode 100755
index 000000000..b8ff915ed
--- /dev/null
+++ b/scripts/checks/shortcut_actions.py
@@ -0,0 +1,260 @@
+#!/usr/bin/env python3
+
+"""
+Checks that every action a key or a menu item reaches is declared the whole way through.
+
+An action is one `ShortcutId`, and naming it is the first of the links it needs: every shipped
+keybinding scheme states the combination that fires it, `KeybindingActionElements` names it so the
+keybindings editor can list it, and an application-scope action states the call it makes — either
+its own entry in the shell's binding map, or membership of a family that maps a whole enum onto
+one call.
+
+The scheme a build loads validates itself as it loads, so this check covers what that validation
+cannot reach: the schemes a platform other than this one ships, the editor's vocabulary, and the
+call behind a menu item, which otherwise goes missing until the application is constructed.
+
+Four things are reported:
+ unanswered action — an action a shipped scheme states no combination for
+ unnamed action — a rebindable action `KeybindingActionElements` has no member for
+ uncalled action — an application-scope action no binding and no family answers
+ stale entry — a name a scheme or the editor states that no action carries
+
+Usage:
+ python scripts/checks/shortcut_actions.py
+"""
+
+import argparse
+import ast
+import sys
+from pathlib import Path
+from typing import (
+ AbstractSet,
+ Final,
+ FrozenSet,
+ Iterable,
+ List,
+ Mapping,
+ NamedTuple,
+ Sequence,
+ Set,
+)
+
+import yaml
+
+from sampletones_application.categories.elements import settings as settings_module
+from sampletones_application.categories.elements.settings import (
+ KeybindingActionElements,
+)
+from sampletones_application.paths import KEYBINDINGS_DIRECTORY
+from sampletones_application.utils.gui.shortcuts import ids as ids_module
+from sampletones_application.utils.gui.shortcuts.ids import (
+ EDITABLE_SHORTCUT_CATEGORIES,
+ FAMILY_SHORTCUT_IDS,
+ ShortcutCategory,
+ ShortcutId,
+)
+from sampletones_shared.meta.source.modules import SourceModule, parse_module
+from sampletones_shared.meta.source.packages import package_directory
+
+SHORTCUTS_MODULE: Final[Path] = Path(ids_module.__file__)
+ELEMENTS_MODULE: Final[Path] = Path(settings_module.__file__)
+SHELL_MODULE: Final[Path] = package_directory("sampletones_application") / "shell.py"
+
+SCHEME_ENCODING: Final[str] = "utf-8"
+SCHEME_BINDINGS_FIELD: Final[str] = "bindings"
+SHORTCUT_ID_CLASS: Final[str] = "ShortcutId"
+
+UNANSWERED_ACTION: Final[str] = "unanswered action"
+UNNAMED_ACTION: Final[str] = "unnamed action"
+UNCALLED_ACTION: Final[str] = "uncalled action"
+STALE_ENTRY: Final[str] = "stale entry"
+
+CALLED_RULE: Final[str] = (
+ "an application-scope action states the call it makes, as an entry in the shell's binding map "
+ "or as a member of FAMILY_SHORTCUT_IDS, whose call is dispatched from an enum"
+)
+
+
+class Finding(NamedTuple):
+ """One thing the check reports, named by kind and located where a reader can open it."""
+
+ kind: str
+ location: str
+ message: str
+
+
+def editable_actions(actions: Iterable[ShortcutId]) -> FrozenSet[ShortcutId]:
+ """The actions a reader may rebind, which is the set the keybindings editor lists.
+
+ A dialog is operated by the keys its own category holds, so those stay as they are and the
+ editor names them nowhere.
+ """
+ return frozenset(action for action in actions if action.category in EDITABLE_SHORTCUT_CATEGORIES)
+
+
+def scheme_files(directory: Path = KEYBINDINGS_DIRECTORY) -> List[Path]:
+ """Every keybinding scheme a directory ships, in name order."""
+ return sorted(directory.glob("*.yaml"))
+
+
+def scheme_actions(path: Path) -> Set[str]:
+ """The action names one scheme states a combination for."""
+ document = yaml.safe_load(path.read_text(encoding=SCHEME_ENCODING))
+ bindings = (
+ document.get(SCHEME_BINDINGS_FIELD)
+ if isinstance(
+ document,
+ dict,
+ )
+ else None
+ )
+ return set(bindings) if isinstance(bindings, dict) else set()
+
+
+def mapping_keys(module: SourceModule) -> Set[str]:
+ """Every action a module names as a mapping key, which is how the shell states its calls.
+
+ The shell builds its map inside the method that takes the bindings, so the map is read from the
+ source rather than imported. Families are declared instead, and are read as what they declare.
+ """
+ return {
+ key.attr
+ for node in ast.walk(module.tree)
+ if isinstance(node, ast.Dict)
+ for key in node.keys
+ if isinstance(key, ast.Attribute)
+ and isinstance(
+ key.value,
+ ast.Name,
+ )
+ and key.value.id == SHORTCUT_ID_CLASS
+ }
+
+
+def unanswered_actions(
+ schemes: Mapping[Path, Set[str]],
+ actions: Iterable[ShortcutId],
+) -> List[Finding]:
+ """Every action a shipped scheme states no combination for."""
+ return [
+ Finding(
+ kind=UNANSWERED_ACTION,
+ location=str(path),
+ message=f"this scheme states no combination for {action.value!r}",
+ )
+ for path, stated in schemes.items()
+ for action in actions
+ if action.value not in stated
+ ]
+
+
+def unnamed_actions(
+ actions: Iterable[ShortcutId],
+ named: AbstractSet[str],
+) -> List[Finding]:
+ """Every rebindable action the keybindings editor has no member to name."""
+ return [
+ Finding(
+ kind=UNNAMED_ACTION,
+ location=str(SHORTCUTS_MODULE),
+ message=f"{action.name} has no KeybindingActionElements member, so the editor cannot list it",
+ )
+ for action in sorted(
+ editable_actions(actions),
+ key=lambda action: action.name,
+ )
+ if action.name not in named
+ ]
+
+
+def uncalled_actions(
+ actions: Iterable[ShortcutId],
+ answered: AbstractSet[str],
+) -> List[Finding]:
+ """Every application-scope action no call stands behind."""
+ return [
+ Finding(
+ kind=UNCALLED_ACTION,
+ location=str(SHELL_MODULE),
+ message=f"{action.name} reaches no call: {CALLED_RULE}",
+ )
+ for action in actions
+ if action.category is ShortcutCategory.APPLICATION and action.name not in answered
+ ]
+
+
+def stale_scheme_entries(
+ schemes: Mapping[Path, Set[str]],
+ actions: Iterable[ShortcutId],
+) -> List[Finding]:
+ """Every name a shipped scheme states that no action carries."""
+ values = {action.value for action in actions}
+ return [
+ Finding(
+ kind=STALE_ENTRY,
+ location=str(path),
+ message=f"{stated!r} names no action this build carries",
+ )
+ for path, entries in schemes.items()
+ for stated in sorted(entries - values)
+ ]
+
+
+def stale_editor_entries(
+ elements: Iterable[KeybindingActionElements],
+ actions: Iterable[ShortcutId],
+) -> List[Finding]:
+ """Every name the keybindings editor states that no rebindable action carries."""
+ names = {action.name for action in editable_actions(actions)}
+ return [
+ Finding(
+ kind=STALE_ENTRY,
+ location=str(ELEMENTS_MODULE),
+ message=f"KeybindingActionElements.{element.name} names no action this build carries",
+ )
+ for element in elements
+ if element.name not in names
+ ]
+
+
+def check() -> List[Finding]:
+ """Every link of the chain, over every shipped scheme."""
+ actions = tuple(ShortcutId)
+ elements = tuple(KeybindingActionElements)
+ schemes = {path: scheme_actions(path) for path in scheme_files()}
+ answered = mapping_keys(parse_module(SHELL_MODULE)) | {action.name for action in FAMILY_SHORTCUT_IDS}
+ named = {element.name for element in elements}
+
+ return [
+ *unanswered_actions(schemes, actions),
+ *unnamed_actions(actions, named),
+ *uncalled_actions(actions, answered),
+ *stale_scheme_entries(schemes, actions),
+ *stale_editor_entries(elements, actions),
+ ]
+
+
+def main(argv: Sequence[str]) -> int:
+ """Report every action left short of a combination, a name, or the call it makes."""
+ parser = argparse.ArgumentParser(
+ description="Check that every action is declared the whole way through.",
+ )
+ parser.parse_args(list(argv))
+
+ findings = check()
+ if not findings:
+ return 0
+
+ print("Action(s) declared only part of the way:", file=sys.stderr)
+ for kind, location, message in findings:
+ print(f" {kind} | {location}: {message}", file=sys.stderr)
+
+ print(
+ f"\nFound {len(findings)} incomplete action declaration(s).",
+ file=sys.stderr,
+ )
+ return 1
+
+
+if __name__ == "__main__":
+ raise SystemExit(main(sys.argv[1:]))
diff --git a/scripts/ci/checks/bundle.py b/scripts/ci/checks/bundle.py
index 08fd64dbb..acef01234 100644
--- a/scripts/ci/checks/bundle.py
+++ b/scripts/ci/checks/bundle.py
@@ -27,7 +27,7 @@ def launcher_path(bundle: Path, *, system: str) -> Path:
def missing_notices(bundle: Path) -> List[str]:
- """The licence and notice files a release bundle must ship that are absent from it."""
+ """The license and notice files a release bundle must ship that are absent from it."""
return [name for name in REQUIRED_NOTICES if not (bundle / name).is_file()]
diff --git a/scripts/linux/build/build.sh b/scripts/linux/build/build.sh
index 3fd6327e8..198b0acf7 100755
--- a/scripts/linux/build/build.sh
+++ b/scripts/linux/build/build.sh
@@ -44,6 +44,7 @@ echo "Building executable..."
--add-data "src/sampletones_assets/icons:assets/icons" \
--add-data "src/sampletones_assets/fonts:assets/fonts" \
--add-data "src/sampletones_config:config" \
+ --add-data "src/sampletones_player/driver/binary:sampletones_player/driver/binary" \
--copy-metadata sampletones \
--exclude-module PIL \
"${RELEASE_HOOK_ARGS[@]}" \
diff --git a/scripts/linux/dev/tests.sh b/scripts/linux/dev/tests.sh
index bbd392299..46d4f865a 100755
--- a/scripts/linux/dev/tests.sh
+++ b/scripts/linux/dev/tests.sh
@@ -6,11 +6,15 @@ echo "Running doctests..."
uv run python -m pytest src/ --doctest-modules --no-cov
DOCTEST_EXIT=$?
+echo "Running benchmarks..."
+uv run python -m pytest tests/benchmarks --no-cov
+BENCHMARK_EXIT=$?
+
echo "Running pytest with coverage..."
-uv run python -m pytest -n 6 --cov
+uv run python -m pytest -n 6 --cov --ignore=tests/benchmarks
PYTEST_EXIT=$?
-if [[ $DOCTEST_EXIT -ne 0 ]] || [[ $PYTEST_EXIT -ne 0 ]]; then
+if [[ $DOCTEST_EXIT -ne 0 ]] || [[ $PYTEST_EXIT -ne 0 ]] || [[ $BENCHMARK_EXIT -ne 0 ]]; then
echo "Tests failed."
exit 1
fi
diff --git a/scripts/nsf_render.py b/scripts/nsf_render.py
new file mode 100755
index 000000000..922c58407
--- /dev/null
+++ b/scripts/nsf_render.py
@@ -0,0 +1,171 @@
+#!/usr/bin/env python3
+
+import argparse
+import struct
+import subprocess
+import sys
+from pathlib import Path
+from typing import Dict, Final, List, Sequence
+
+from sampletones_player.driver.image import DriverImage
+from sampletones_player.specification.clock import (
+ FIXED_POINT_SCALE,
+ NTSC_FRAME_RATE,
+)
+from sampletones_player.specification.nsf import HEADER_SIZE
+from sampletones_player.specification.song import (
+ STEP_FRACTION_OFFSET,
+ STEP_WHOLE_OFFSET,
+ TOTAL_TICKS_OFFSET,
+)
+from sampletones_shared.paths.extensions import EXT_FILE_NSF, EXT_FILE_WAVE
+from sampletones_shared.utils.system.programs import (
+ locate_program,
+ missing_program_message,
+)
+from sampletones_shared.utils.system.system import System
+
+SAMPLES_DIRECTORY: Final[Path] = Path("build") / "nsf"
+TAIL_SECONDS: Final[float] = 0.5
+FFMPEG: Final[str] = "ffmpeg"
+GME_FORMAT: Final[str] = "libgme"
+WORD: Final[str] = " bool:
+ """Whether the installed ffmpeg carries the demuxer an exported file is read through.
+
+ A demuxer is a build option, so ffmpeg is asked which ones it carries rather than taken to
+ carry this one.
+
+ Returns:
+ bool: True where ffmpeg reports the libgme demuxer among its own.
+
+ Raises:
+ CalledProcessError: If ffmpeg fails to report its demuxers.
+ """
+ reported = subprocess.run(
+ [FFMPEG, "-hide_banner", "-loglevel", "error", "-demuxers"],
+ capture_output=True,
+ text=True,
+ check=True,
+ )
+ return GME_FORMAT in reported.stdout
+
+
+def song_seconds(data: bytes, code_length: int) -> float:
+ """How long the song in an exported file lasts, read out of the block behind the driver.
+
+ The block states the ticks the song covers and the step the driver advances them by, which
+ gives the play calls the song takes, and the console makes one of those every video frame.
+
+ Args:
+ data: The whole `.nsf` file, header included.
+ code_length: The length of the driver the file carries.
+
+ Returns:
+ float: The seconds the song lasts.
+ """
+ block = data[HEADER_SIZE + code_length :]
+ ticks: int = struct.unpack_from(WORD, block, TOTAL_TICKS_OFFSET)[0]
+ fraction: int = struct.unpack_from(WORD, block, STEP_FRACTION_OFFSET)[0]
+ step: float = block[STEP_WHOLE_OFFSET] + fraction / FIXED_POINT_SCALE
+ play_calls = ticks / step
+ return play_calls / float(NTSC_FRAME_RATE)
+
+
+def render(source: Path, destination: Path, seconds: float) -> None:
+ """Decodes one exported file to a wave through libgme's own 2A03.
+
+ Args:
+ source: The `.nsf` file to play.
+ destination: Where the rendered wave is written.
+ seconds: How much of the song to render.
+
+ Raises:
+ CalledProcessError: If ffmpeg rejects the file.
+ """
+ subprocess.run(
+ [
+ FFMPEG,
+ "-y",
+ "-loglevel",
+ "error",
+ "-f",
+ GME_FORMAT,
+ "-i",
+ str(source),
+ "-t",
+ f"{seconds:.3f}",
+ str(destination),
+ ],
+ check=True,
+ )
+
+
+def main(argv: Sequence[str]) -> int:
+ """Renders every exported file in a directory to a wave beside it."""
+
+ parser = argparse.ArgumentParser(
+ description="Render exported .nsf files to waves with ffmpeg's libgme demuxer.",
+ )
+ parser.add_argument(
+ "--directory",
+ type=Path,
+ default=SAMPLES_DIRECTORY,
+ help="directory holding the exported .nsf files",
+ )
+ parser.add_argument(
+ "--tail",
+ type=float,
+ default=TAIL_SECONDS,
+ help="seconds to keep past the end of each song",
+ )
+ arguments = parser.parse_args(list(argv))
+
+ if locate_program(FFMPEG) is None:
+ print(missing_program_message(FFMPEG, RENDER_PURPOSE, INSTALL_HINTS), file=sys.stderr)
+ return 1
+
+ if not decodes_exports():
+ print(
+ f"{FFMPEG} reports no {GME_FORMAT} demuxer; rendering needs a build made with --enable-libgme",
+ file=sys.stderr,
+ )
+ return 1
+
+ sources: List[Path] = sorted(arguments.directory.glob(f"*{EXT_FILE_NSF}"))
+ if not sources:
+ print(
+ f"no {EXT_FILE_NSF} files in {arguments.directory}; run make nsf-samples first",
+ file=sys.stderr,
+ )
+ return 1
+
+ code_length = len(DriverImage.load().code)
+ for source in sources:
+ destination = source.with_suffix(EXT_FILE_WAVE)
+ seconds = song_seconds(source.read_bytes(), code_length) + arguments.tail
+ try:
+ render(source, destination, seconds)
+ except subprocess.CalledProcessError as error:
+ print(
+ f"{FFMPEG} rejected {source}: exit status {error.returncode}",
+ file=sys.stderr,
+ )
+ return 1
+
+ print(f"{destination} {seconds:.3f} s")
+
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main(sys.argv[1:]))
diff --git a/scripts/player.py b/scripts/player.py
new file mode 100755
index 000000000..d96e2847c
--- /dev/null
+++ b/scripts/player.py
@@ -0,0 +1,43 @@
+#!/usr/bin/env python3
+
+import argparse
+import sys
+from pathlib import Path
+from typing import Sequence
+
+from sampletones_player.driver.assembler.builder import build_driver
+from sampletones_player.driver.assembler.layout import BINARY_DIRECTORY
+from sampletones_player.specification.driver import DRIVER_CODE_NAME
+from sampletones_shared.exceptions import DriverBuildError
+
+
+def main(argv: Sequence[str]) -> int:
+ """Assembles the NES player driver and reports the layout the build produced."""
+
+ parser = argparse.ArgumentParser(
+ description="Assemble the NES player driver with cc65.",
+ )
+ parser.add_argument(
+ "--directory",
+ type=Path,
+ default=BINARY_DIRECTORY,
+ help="directory receiving the assembled driver",
+ )
+ arguments = parser.parse_args(list(argv))
+
+ try:
+ image = build_driver(arguments.directory)
+ except DriverBuildError as error:
+ print(error, file=sys.stderr)
+ return 1
+
+ addresses = image.addresses
+ print(f"{DRIVER_CODE_NAME} {len(image.code)} bytes, ${addresses.load:04X}-${addresses.song - 1:04X}")
+ print(f"init ${addresses.init:04X}")
+ print(f"play ${addresses.play:04X}")
+ print(f"song ${addresses.song:04X}")
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main(sys.argv[1:]))
diff --git a/scripts/windows/build/build.bat b/scripts/windows/build/build.bat
index 915dc4713..f2567962c 100644
--- a/scripts/windows/build/build.bat
+++ b/scripts/windows/build/build.bat
@@ -51,6 +51,7 @@ echo Building executable...
--add-data "src\sampletones_assets\icons;assets\icons" ^
--add-data "src\sampletones_assets\fonts;assets\fonts" ^
--add-data "src\sampletones_config;config" ^
+ --add-data "src\sampletones_player\driver\binary;sampletones_player\driver\binary" ^
--copy-metadata sampletones ^
--exclude-module PIL ^
%RELEASE_HOOK% ^
diff --git a/scripts/windows/dev/tests.bat b/scripts/windows/dev/tests.bat
index 1a9fa0c73..fd0d1659b 100644
--- a/scripts/windows/dev/tests.bat
+++ b/scripts/windows/dev/tests.bat
@@ -5,8 +5,12 @@ echo Running doctests...
uv run python -m pytest src/ --doctest-modules --no-cov
set DOCTEST_EXIT=%ERRORLEVEL%
+echo Running benchmarks...
+uv run python -m pytest tests/benchmarks --no-cov
+set BENCHMARK_EXIT=%ERRORLEVEL%
+
echo Running pytest with coverage...
-uv run python -m pytest -n 6 --cov
+uv run python -m pytest -n 6 --cov --ignore=tests/benchmarks
set PYTEST_EXIT=%ERRORLEVEL%
if not %DOCTEST_EXIT%==0 (
@@ -19,5 +23,10 @@ if not %PYTEST_EXIT%==0 (
exit /b 1
)
+if not %BENCHMARK_EXIT%==0 (
+ echo Tests failed.
+ exit /b 1
+)
+
echo All tests passed.
exit /b 0
diff --git a/src/sampletones/__init__.py b/src/sampletones/__init__.py
index 936911a5e..60d9ed3cc 100644
--- a/src/sampletones/__init__.py
+++ b/src/sampletones/__init__.py
@@ -3,7 +3,7 @@
if TYPE_CHECKING:
from sampletones_core.configs import Config
- from sampletones_core.constants.enums import GeneratorName
+ from sampletones_core.constants.enums import ChannelName
from sampletones_core.fft import Window
from sampletones_core.generators import (
Generator,
@@ -33,10 +33,10 @@ def __getattr__(name: str) -> Any:
return SAMPLETONES_VERSION
- if name == "GeneratorName":
- from sampletones_core.constants.enums import GeneratorName
+ if name == "ChannelName":
+ from sampletones_core.constants.enums import ChannelName
- return GeneratorName
+ return ChannelName
if name == "Window":
from sampletones_core.fft import Window
@@ -76,7 +76,7 @@ def __getattr__(name: str) -> Any:
__all__ = [
"Config",
"Generator",
- "GeneratorName",
+ "ChannelName",
"Instruction",
"InstructionLibrary",
"NoiseGenerator",
diff --git a/src/sampletones/__main__.py b/src/sampletones/__main__.py
index b7625bb23..dc3dcb875 100644
--- a/src/sampletones/__main__.py
+++ b/src/sampletones/__main__.py
@@ -116,6 +116,10 @@ def main() -> None:
raise SystemExit(run_self_check())
+ from sampletones_shared.array import report_array_backend
+
+ report_array_backend()
+
config_path = Path(args.config) if args.config else None
output_path = Path(args.output) if args.output else None
diff --git a/src/sampletones/self_check.py b/src/sampletones/self_check.py
index ff5bb192d..30fe3b407 100644
--- a/src/sampletones/self_check.py
+++ b/src/sampletones/self_check.py
@@ -86,7 +86,7 @@ def _check_keybindings() -> str:
def _check_layout_config() -> str:
- """Resolves the layout against every shipped palette, since each answers the colour tokens itself."""
+ """Resolves the layout against every shipped palette, since each answers the color tokens itself."""
from sampletones_application.layout import LayoutConfig, load_layout_config
from sampletones_application.paths import BEHAVIOR_DIRECTORY, LAYOUT_DIRECTORY
@@ -97,7 +97,7 @@ def _check_layout_config() -> str:
def _check_themes() -> str:
- """Resolves the theme set against every shipped palette, since each answers the colour tokens itself."""
+ """Resolves the theme set against every shipped palette, since each answers the color tokens itself."""
from sampletones_application.paths import THEME_DIRECTORY
from sampletones_application.ui.themes.loader import ThemeLoader
@@ -126,6 +126,17 @@ def _check_resources() -> str:
return f"{len(FontResource)} fonts, {len(IconResource)} icons"
+def _check_export_backends() -> str:
+ """Composes every backend the application exports through.
+
+ A backend reads the resources it writes with as it is built, so this is where a build
+ shipping without one — the player's assembled driver among them — names what is missing.
+ """
+ from sampletones_application.exports import build_export_backends
+
+ return ", ".join(sorted(build_export_backends()))
+
+
def _check_file_dialog_backend() -> str:
from sampletones_application.utils.file_dialogs.selection import select_file_dialog_backend
@@ -141,6 +152,7 @@ def _check_file_dialog_backend() -> str:
SelfCheck(name="themes", run=_check_themes),
SelfCheck(name="language", run=_check_language),
SelfCheck(name="resources", run=_check_resources),
+ SelfCheck(name="export backends", run=_check_export_backends),
SelfCheck(name="file dialog backend", run=_check_file_dialog_backend),
)
diff --git a/src/sampletones_application/application.py b/src/sampletones_application/application.py
index ecb423e3c..1dbd25f28 100644
--- a/src/sampletones_application/application.py
+++ b/src/sampletones_application/application.py
@@ -16,6 +16,10 @@
from sampletones_application.coordinators.config import ConfigCoordinator
from sampletones_application.coordinators.display import DisplayCoordinator
from sampletones_application.coordinators.edit.router import EditRouter
+from sampletones_application.coordinators.export import (
+ InstrumentExportCoordinator,
+ SongExportCoordinator,
+)
from sampletones_application.coordinators.keybindings import KeybindingsCoordinator
from sampletones_application.coordinators.original_audio import OriginalAudioLocator
from sampletones_application.coordinators.playback.protocol import AudioPlayerProtocol
@@ -32,8 +36,11 @@
from sampletones_application.coordinators.tabs.reconstruction import (
ReconstructionTabCoordinator,
)
-from sampletones_application.coordinators.tabs.sequencer import SequencerTabCoordinator
+from sampletones_application.coordinators.tabs.sequencer.coordinator import SequencerTabCoordinator
+from sampletones_application.exports import build_export_backends
from sampletones_application.layout import LayoutConfig, load_layout_config
+from sampletones_application.logic.export import SongExportLogic
+from sampletones_application.logic.export.instrument.logic import InstrumentExportLogic
from sampletones_application.logic.history.action import HistoryAction
from sampletones_application.logic.history.manager import HistoryManager
from sampletones_application.logic.instruction.library_manager import (
@@ -47,6 +54,11 @@
document_title,
)
from sampletones_application.logic.reconstruction.browser.manager import BrowserManager
+from sampletones_application.logic.reconstruction.edit import (
+ ChannelEdit,
+ ReconstructionEdit,
+ StemRemoval,
+)
from sampletones_application.logic.reconstruction.manager import ReconstructionManager
from sampletones_application.logic.render import SongRenderLogic
from sampletones_application.parameters import (
@@ -66,14 +78,15 @@
)
from sampletones_application.services import (
ConversionService,
+ ExportResult,
ExportService,
- RegeneratedInstrument,
RegenerationService,
RetunedSample,
RetuneResult,
SampleRetuneService,
- ServiceCancelled,
+ ServiceCanceled,
ServiceError,
+ ServiceProgress,
ServiceSuccess,
SongRenderService,
)
@@ -101,11 +114,13 @@
from sampletones_application.ui.panels.dialogs.display_settings import (
GUIDisplaySettingsWindow,
)
+from sampletones_application.ui.panels.dialogs.export import GUIExportWindow
from sampletones_application.ui.panels.dialogs.keybindings import GUIKeybindingsWindow
from sampletones_application.ui.panels.dialogs.project_properties import (
GUIProjectPropertiesWindow,
)
from sampletones_application.ui.panels.dialogs.render import GUIRenderWindow
+from sampletones_application.ui.panels.dialogs.stem_selection import GUIStemSelectionWindow
from sampletones_application.ui.themes.registry import ThemeRegistry
from sampletones_application.ui.themes.setup import setup_themes
from sampletones_application.utils.callbacks.queue import CallbackQueue
@@ -133,6 +148,7 @@
from sampletones_application.view_model.shared.audio_settings import (
AudioSettingsViewModel,
)
+from sampletones_application.view_model.shared.history import HistoryDetail
from sampletones_application.view_model.shared.menu import MenuBarViewModel
from sampletones_application.view_model.shared.project_properties import (
ProjectPropertiesViewModel,
@@ -140,15 +156,16 @@
from sampletones_application.viewport import ViewportManager
from sampletones_core.audio import AudioDeviceManager
from sampletones_core.constants.audio import BufferSize, SampleRate
-from sampletones_core.constants.enums import FeatureKey, GeneratorName
+from sampletones_core.constants.enums import ChannelName, FeatureKey
from sampletones_core.exporters import Features
-from sampletones_core.project.instruments.sample import Sample
+from sampletones_core.exports.backend import ExportBackend
+from sampletones_core.exports.format import ExportFormat
+from sampletones_core.exports.stage import ExportStage
+from sampletones_core.project.voices.instrument import Instrument
+from sampletones_core.project.voices.sample import Sample
+from sampletones_core.project.voices.voice import samples
from sampletones_core.reconstructions import Reconstruction
from sampletones_core.structures.tree import FileSystemNode
-from sampletones_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,
SAMPLETONES_GROUP,
@@ -235,11 +252,12 @@ def __init__(
self.conversion_service: ConversionService = ConversionService(priority=_priority)
self.regeneration_service: RegenerationService = RegenerationService(priority=_priority)
self.export_service: ExportService = ExportService(priority=_priority)
+ self.export_service.subscribe(self._on_export_activity)
self.render_service: SongRenderService = SongRenderService(priority=_priority)
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.export_backends: Dict[ExportFormat, ExportBackend] = build_export_backends()
self.project_manager: ProjectManager = ProjectManager()
self.project_controller: ProjectController = ProjectController(self.project_manager)
@@ -287,6 +305,16 @@ def __init__(
key_router=self.key_router,
shortcut_source=self._shortcut_source,
)
+ self.stem_selection_window: GUIStemSelectionWindow = GUIStemSelectionWindow(
+ layout=self.layout.tabs.main.converter,
+ title=self.language_manager["main.converter.title.stem_selection_dialog"],
+ message=self.language_manager["main.converter.message.stem_selection_prompt"],
+ limit_template=self.language_manager["main.converter.template.stem_selection_limit"],
+ add_label=self.language_manager["main.converter.label.add_stems_button"],
+ cancel_label=self.language_manager["global.dialog.label.cancel"],
+ key_router=self.key_router,
+ shortcut_source=self._shortcut_source,
+ )
self.render_window: GUIRenderWindow = GUIRenderWindow(
layout=self.layout.settings,
path_colors=self.layout.general.colors.paths,
@@ -295,6 +323,13 @@ def __init__(
shortcut_source=self._shortcut_source,
status_bar=self.status_bar,
)
+ self.export_window: GUIExportWindow = GUIExportWindow(
+ layout=self.layout.settings,
+ text_colors=self.layout.general.colors.text,
+ language_manager=self.language_manager,
+ key_router=self.key_router,
+ shortcut_source=self._shortcut_source,
+ )
self.project_properties_window: GUIProjectPropertiesWindow = GUIProjectPropertiesWindow(
layout=self.layout.project_properties,
language_manager=self.language_manager,
@@ -316,6 +351,7 @@ def __init__(
player_layout=self.layout.player,
language_manager=self.language_manager,
build_edit_actions=self._build_edit_actions,
+ build_voice_actions=self._build_voice_actions,
on_play_from_start=self._play_from_start,
on_pause_or_resume=self._play,
on_stop=self._stop,
@@ -357,7 +393,7 @@ def __init__(
self.project_manager,
self.session_manager,
self.export_service,
- tracker_backends=self.tracker_backends,
+ export_backends=self.export_backends,
dialogs=self.dialogs,
language_manager=self.language_manager,
on_tab_switch=self._set_current_tab,
@@ -382,19 +418,36 @@ def __init__(
language_manager=self.language_manager,
)
+ self._instrument_exports = InstrumentExportCoordinator(
+ InstrumentExportLogic(
+ self.project_controller,
+ self.session_manager,
+ self.export_service,
+ self.export_backends,
+ ),
+ self.language_manager,
+ )
+
self._reconstructions_tab = ReconstructionTabCoordinator(
config_manager=self.config_manager,
session_manager=self.session_manager,
audio_device_manager=self.audio_device_manager,
reconstruction_manager=self.reconstruction_manager,
+ project_controller=self.project_controller,
browser_manager=self.browser_manager,
export_service=self.export_service,
- tracker_backends=self.tracker_backends,
+ export_backends=self.export_backends,
on_load_reconstruction_with_confirmation=self._reconstruction_coordinator.load_with_confirmation,
on_change_audio_state=self._update_menu,
on_favorite_changed=self._repaint_reconstruction_favorites,
on_reconstruction_instrument_updated=self._regenerate_instrument,
+ on_reconstruction_stem_removed=self._reconstruction_coordinator.apply_edit,
original_audio_locator=self._original_audio_locator,
+ instrument_exports=self._instrument_exports,
+ history=self.history,
+ instrument_edit_detail=self._instrument_edit_detail,
+ key_router=self.key_router,
+ tab_active=self._is_reconstructions_tab_current,
layout=ReconstructionTabParameters.from_config(self.layout),
language_manager=self.language_manager,
dialogs=self.dialogs,
@@ -436,9 +489,10 @@ def __init__(
status_bar=self.status_bar,
on_load_file=self._on_converted_reconstruction_loaded,
on_load_directory=self._navigate_to_reconstructions,
- on_cancelled=self._refresh_reconstruction_trees,
+ on_canceled=self._refresh_reconstruction_trees,
on_refresh_trees=self._refresh_reconstruction_trees,
on_generate_library=self._instructions_tab.ensure_library_loaded,
+ stem_selection_window=self.stem_selection_window,
)
self._sequencer_tab = SequencerTabCoordinator(
@@ -451,12 +505,13 @@ def __init__(
project_controller=self.project_controller,
history=self.history,
original_audio_locator=self._original_audio_locator,
+ instrument_exports=self._instrument_exports,
tab_active=self._is_sequencer_tab_current,
layout=SequencerTabParameters.from_config(self.layout),
language_manager=self.language_manager,
dialogs=self.dialogs,
status_bar=self.status_bar,
- on_edit_sample_requested=self._edit_project_sample,
+ on_edit_voice_requested=self._edit_project_voice,
on_favorite_changed=self._repaint_reconstruction_favorites,
on_sample_reconstruction_replaced=self._rebind_replaced_sample,
on_tab_switch=self._set_current_tab,
@@ -501,6 +556,22 @@ def __init__(
on_activity_changed=self._on_render_activity_changed,
)
+ self._export_logic = SongExportLogic(
+ self.export_service,
+ stage_labels={
+ ExportStage.WALKING: self.language_manager["settings.export.label.stage_walking"],
+ ExportStage.COMPRESSING: self.language_manager["settings.export.label.stage_compressing"],
+ ExportStage.WRITING: self.language_manager["settings.export.label.stage_writing"],
+ },
+ size_template=self.language_manager["settings.export.template.size"],
+ cancelling_label=self.language_manager["settings.export.message.status_cancelling"],
+ )
+
+ self._export_coordinator = SongExportCoordinator(
+ self._export_logic,
+ window=self.export_window,
+ )
+
self._shell = ApplicationShell(
session_manager=self.session_manager,
language_manager=self.language_manager,
@@ -605,6 +676,9 @@ def _create_shortcut_bindings(self) -> ShortcutBindings:
export_wav=self._export_reconstruction_wav_dialog,
export_instruments=self._export_reconstruction_instruments_dialog,
add_reconstruction_to_sequencer=self._add_current_reconstruction_to_sequencer,
+ new_instrument=self._add_instrument,
+ add_sample_from_file=self._add_sample_from_file,
+ import_instrument=self._import_instrument,
open_reconstruction_in_explorer=self._open_reconstruction_in_explorer,
locate_original_audio=self._locate_original_audio,
play=self._play,
@@ -675,11 +749,11 @@ def _on_bindings_changed(self, _scheme: ShortcutScheme) -> None:
self.shortcut_manager.rebind()
def _on_palette_changed(self, _palette: Palette) -> None:
- """Repaints what holds a colour DearPyGui has copied, once another palette is in place.
+ """Repaints what holds a color DearPyGui has copied, once another palette is in place.
- Every layout and theme colour already answers with the new palette, so the work left is
- handing those values to the copies DearPyGui keeps: the registered theme colours and item
- arguments, the viewport clear colour, and the sequencer tables, whose tints belong to the
+ Every layout and theme color already answers with the new palette, so the work left is
+ handing those values to the copies DearPyGui keeps: the registered theme colors and item
+ arguments, the viewport clear color, and the sequencer tables, whose tints belong to the
table rather than to an item.
"""
PaletteBindings.apply()
@@ -701,7 +775,7 @@ def _build_initial_menu_state(self) -> MenuBarViewModel:
reconstruction_saveable=self._reconstruction_coordinator.is_saveable(),
reconstruction_in_project=self._editing_project_sample(),
reconstruction_file_backed=self._reconstruction_coordinator.is_saveable(),
- reconstruction_audio_recorded=self.reconstruction_manager.audio_filepath is not None,
+ reconstruction_audio_recorded=bool(self.reconstruction_manager.source_paths),
operation_active=self._is_operation_active(),
can_undo=self.history.can_undo,
can_redo=self.history.can_redo,
@@ -722,6 +796,14 @@ def _build_initial_menu_state(self) -> MenuBarViewModel:
auto_expand_favorite_directories=self.session_manager.auto_expand_favorite_directories,
)
+ def _is_reconstructions_tab_current(self) -> bool:
+ """Whether the Reconstructions tab is in front, which is what puts its panels on the keyboard.
+
+ The instruments panel keeps the voice it is editing while another tab is worked on, so this
+ is what tells a note key meant to sound that voice from one meant for the song's grid.
+ """
+ return self._shell.get_current_tab() == Tab.RECONSTRUCTIONS
+
def _is_sequencer_tab_current(self) -> bool:
"""Whether the Sequencer is the tab in front, which is what puts its panels on the keyboard.
@@ -742,7 +824,7 @@ def _build_menu_bar_viewmodel(self) -> MenuBarViewModel:
reconstruction_saveable=self._reconstruction_coordinator.is_saveable(),
reconstruction_in_project=self._editing_project_sample(),
reconstruction_file_backed=self._reconstruction_coordinator.is_saveable(),
- reconstruction_audio_recorded=self.reconstruction_manager.audio_filepath is not None,
+ reconstruction_audio_recorded=bool(self.reconstruction_manager.source_paths),
operation_active=self._is_operation_active(),
can_undo=self.history.can_undo,
can_redo=self.history.can_redo,
@@ -870,14 +952,29 @@ def _is_operation_active(self) -> bool:
self._main_tab.is_converter_active()
or self._instructions_tab.is_library_generating()
or self._render_coordinator.is_active
+ or self.export_service.is_running()
)
+ def _on_export_activity(self, result: ExportResult) -> None:
+ """Follows an export claiming the application and handing it back.
+
+ A format carrying its own player spends seconds on a song, which is the same ground a
+ conversion or a render occupies, so its edges reach the same busy state. What a run says
+ while it is under way changes nothing about who holds the application, so only its
+ starting and its finishing are edges.
+ """
+ match result:
+ case ServiceProgress():
+ return
+ case _:
+ self._refresh_busy_state()
+
def _refresh_busy_state(self) -> None:
"""Re-evaluate the reconstruct and generate-library buttons whenever a conversion, library
generation or render starts or finishes, keeping the long operations mutually exclusive. Each
panel reads the live ``_is_operation_active`` state for itself; this only nudges them to
re-apply, so the busy truth lives in one place. The menu follows the same edge, since what
- greys an entry offering another such operation is one already running."""
+ grays an entry offering another such operation is one already running."""
self._instructions_tab.refresh_generate_button()
self._update_menu()
@@ -903,9 +1000,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, tracker_format: TrackerFormat) -> None:
+ def _export_reconstruction_instruments_dialog(self, export_format: ExportFormat) -> None:
if self._reconstruction_coordinator.check_loaded():
- self._reconstructions_tab.request_export_instruments_dialog(tracker_format)
+ self._reconstructions_tab.request_export_instruments_dialog(export_format)
def _reconstruct_file(self, filepath: Path) -> None:
self._main_tab.set_input_path(filepath, convert=True)
@@ -960,20 +1057,29 @@ def _repaint_reconstruction_favorites(self, node: FileSystemNode) -> None:
def _navigate_to_reconstructions(self) -> None:
self._set_current_tab(Tab.RECONSTRUCTIONS)
- def _edit_project_sample(self, sample_id: str) -> None:
- sample = self.project_manager.current.sample(sample_id)
- if sample is None:
- logger.warning(f"Cannot edit unknown project sample: {sample_id}")
- return
+ def _edit_project_voice(self, voice_id: str) -> None:
+ """Opens the voice list's selection on the Reconstructions tab, in the terms of its kind.
- self.reconstruction_manager.load_reconstruction_object(
- sample.reconstruction,
- name=sample.name,
- )
+ A sample opens as the reconstruction behind it, waveform and stems and all; an instrument stands
+ on no recording, so the tab shows its envelopes alone. Either kind brings that tab to the
+ front, so the voice a reader asked to edit is the one in view.
+ """
+ match self.project_manager.current.voice(voice_id):
+ case Sample() as sample:
+ self._reconstructions_tab.release_instrument()
+ self.reconstruction_manager.load_reconstruction_object(
+ sample.reconstruction,
+ name=sample.name,
+ )
+ case Instrument():
+ self._reconstructions_tab.edit_instrument(voice_id)
+ self._navigate_to_reconstructions()
+ case _:
+ logger.warning(f"Cannot edit unknown project voice: {voice_id}")
def _rebind_replaced_sample(
self,
- sample_id: str,
+ voice_id: str,
reconstruction: Reconstruction,
) -> None:
"""Points the open Reconstructions-tab document at the reconstruction replacing the one it edits.
@@ -983,43 +1089,35 @@ def _rebind_replaced_sample(
reconstruction, which is what identifies the open document as belonging to it.
Args:
- sample_id: The sample receiving a new reconstruction.
+ voice_id: The sample receiving a new reconstruction.
reconstruction: The reconstruction the sample is about to hold.
"""
- sample = self.project_manager.current.sample(sample_id)
- if sample is None or sample.reconstruction is not self.reconstruction_manager.reconstruction:
+ sample = self.project_manager.current.voice(voice_id)
+ if not isinstance(sample, Sample) or sample.reconstruction is not self.reconstruction_manager.reconstruction:
return
- self.reconstruction_manager.apply_regenerated(reconstruction)
+ self.reconstruction_manager.apply_edited(reconstruction)
self._reconstructions_tab.update_reconstruction()
def _regenerate_instrument(
self,
- generator_name: GeneratorName,
- features: Features,
+ channel_name: ChannelName,
feature_key: FeatureKey,
- feature_value: FeatureValue,
+ features: Features,
) -> None:
- self._reconstruction_coordinator.regenerate_instrument(
- generator_name,
- features,
- feature_key,
- feature_value,
- )
+ self._reconstruction_coordinator.regenerate_instrument(channel_name, feature_key, features)
def _on_reconstruction_updated(
self,
- outcome: RegeneratedInstrument,
+ edit: ReconstructionEdit,
) -> None:
"""Records a reconstruction edit against the project when it owns the sample.
- Regeneration produces a fresh reconstruction. When the edited document is a
+ An edit produces a fresh reconstruction. When the edited document is a
project sample, the sample adopts the new reconstruction as one history
- entry labelled with the channel and feature ``outcome`` names; the
- copy-on-write swap keeps every prior snapshot's reconstruction intact. A
- standalone reconstruction leaves the project untouched. Consecutive edits
- of the same sample coalesce, so a continuous graph movement records a
- single entry.
+ entry the ``edit`` labels and keys; the copy-on-write swap keeps every prior
+ snapshot's reconstruction intact. A standalone reconstruction leaves the
+ project untouched.
"""
sample = self._owning_project_sample()
if sample is None:
@@ -1027,18 +1125,37 @@ def _on_reconstruction_updated(
with self.history.transaction(
HistoryAction.EDIT_RECONSTRUCTION,
- detail=self._sequencer_tab.reconstruction_edit_detail(
- sample.id,
- outcome.generator_name,
- outcome.feature_key,
- ),
- coalesce=(sample.id,),
+ detail=self._edit_detail(sample.id, edit),
+ coalesce=edit.coalesce_key(sample.id),
):
self.project_controller.replace_sample_reconstruction(
sample.id,
- outcome.reconstruction,
+ edit.reconstruction,
)
+ def _instrument_edit_detail(
+ self,
+ voice_id: str,
+ feature_key: FeatureKey,
+ ) -> HistoryDetail:
+ """The history line an instrument edit reads as: the voice and the dimension it moved."""
+ return self._sequencer_tab.instrument_edit_detail(voice_id, feature_key)
+
+ def _edit_detail(self, voice_id: str, edit: ReconstructionEdit) -> HistoryDetail:
+ """The history line an edit reads as: the feature it moved, or the recording it took out."""
+ match edit:
+ case ChannelEdit():
+ return self._sequencer_tab.reconstruction_edit_detail(
+ voice_id,
+ edit.channel_name,
+ edit.feature_key,
+ )
+ case StemRemoval():
+ return self._sequencer_tab.reconstruction_stem_detail(
+ voice_id,
+ edit.stem_name,
+ )
+
def _retune_samples_for_rate(self, nes_frequency: int) -> None:
"""Refreshes the stored reconstructions of samples left out of sync by a rate change.
@@ -1048,7 +1165,7 @@ def _retune_samples_for_rate(self, nes_frequency: int) -> None:
"""
targets = [
(sample.id, sample.reconstruction)
- for sample in self.project_manager.current.samples
+ for sample in samples(self.project_manager.current.voices)
if sample.reconstruction.config.nes_frequency != nes_frequency
]
if not targets:
@@ -1072,7 +1189,7 @@ def _on_retune_result(self, result: RetuneResult) -> None:
self._apply_retuned_sample(retuned)
case ServiceError(exception=exception):
logger.error_with_traceback(exception, "Sample retune failed")
- case ServiceCancelled():
+ case ServiceCanceled():
pass
if not self.retune_service.is_running():
@@ -1088,8 +1205,8 @@ def _apply_retuned_sample(self, retuned: RetunedSample) -> None:
open in the Reconstructions tab rebinds so its editor and the project sample stay one object.
"""
project = self.project_manager.current
- sample = project.samples.get(retuned.sample_id)
- if sample is None:
+ sample = project.voices.get(retuned.voice_id)
+ if not isinstance(sample, Sample):
return
nes_frequency = retuned.reconstruction.config.nes_frequency
@@ -1103,12 +1220,12 @@ def _apply_retuned_sample(self, retuned: RetunedSample) -> None:
coalesce=(nes_frequency,),
):
self.project_controller.replace_sample_reconstruction(
- retuned.sample_id,
+ retuned.voice_id,
retuned.reconstruction,
)
if is_open:
- self.reconstruction_manager.apply_regenerated(
+ self.reconstruction_manager.apply_edited(
retuned.reconstruction,
)
self._reconstructions_tab.update_reconstruction()
@@ -1247,7 +1364,7 @@ def _owning_project_sample(self) -> Optional[Sample]:
if reconstruction is None:
return None
- for sample in self.project_manager.current.samples:
+ for sample in samples(self.project_manager.current.voices):
if sample.reconstruction is reconstruction:
return sample
@@ -1280,7 +1397,7 @@ def _reconstruction_title_part(self) -> Optional[ReconstructionTitlePart]:
unsaved_changes = self._reconstruction_coordinator.is_unsaved()
sample = self._owning_project_sample()
if sample is not None:
- ordinal = self.project_manager.current.samples.get_index(sample.id)
+ ordinal = self.project_manager.current.voices.get_index(sample.id)
name = SEQUENCER_SAMPLE_TITLE_FORMAT.format(
ordinal=format(ordinal, SEQUENCER_SAMPLE_ORDINAL_FORMAT),
name=sample.name,
@@ -1369,7 +1486,7 @@ def _persist_application_state(self) -> None:
def _save_browser_shapes(self) -> None:
"""Asks every tab holding a tree to write down which of its rows stand open.
- The shape belongs to the browser showing it, and it is read the once here rather than followed
+ The instrument belongs to the browser showing it, and it is read the once here rather than followed
row by row, a pass over the rows running on the tree worker.
"""
self._main_tab.save_browser_shape()
@@ -1381,6 +1498,22 @@ def _build_edit_actions(self) -> bool:
"""States the actions of the grid holding the cursor into the Edit menu being built."""
return self._edit_router.build_menu_actions()
+ def _build_voice_actions(self) -> None:
+ """States the chosen voice's actions into the Voice menu being built."""
+ self._sequencer_tab.build_voice_actions()
+
+ def _add_instrument(self) -> None:
+ """Writes a voice by hand into the open project's pool."""
+ self._sequencer_tab.add_instrument()
+
+ def _add_sample_from_file(self) -> None:
+ """Brings a reconstruction saved anywhere on disk into the pool as a sample."""
+ self._sequencer_tab.add_sample_from_file()
+
+ def _import_instrument(self) -> None:
+ """Brings a FamiTracker instrument file into the pool as an instrument voice."""
+ self._sequencer_tab.import_instrument()
+
def _play_from_start(self) -> None:
self._playback_router.play_from_start()
self._update_menu()
@@ -1401,7 +1534,7 @@ def _stop(self) -> None:
self._playback_router.stop()
self._update_menu()
- def _toggle_channel(self, generator: GeneratorName) -> None:
+ def _toggle_channel(self, generator: ChannelName) -> None:
"""Switches one NES channel in the tab in front of the reader.
A channel is switched by a control of its own on three tabs: the generators a
@@ -1411,13 +1544,13 @@ def _toggle_channel(self, generator: GeneratorName) -> None:
"""
match self._shell.get_current_tab():
case Tab.MAIN:
- self._main_tab.toggle_generator(generator)
+ self._main_tab.toggle_channel(generator)
case Tab.RECONSTRUCTIONS:
- self._reconstructions_tab.toggle_generator(generator)
+ self._reconstructions_tab.toggle_channel(generator)
case _:
self._mute_channel(generator)
- def _mute_channel(self, generator: GeneratorName) -> None:
+ def _mute_channel(self, generator: ChannelName) -> None:
"""Flips one channel of the sequencer's mix, the gesture the Channels submenu offers."""
self._sequencer_tab.toggle_channel(generator)
@@ -1467,6 +1600,7 @@ def _is_project_open(self) -> bool:
def _exit_application(self) -> None:
self._render_coordinator.cleanup()
+ self._export_coordinator.cleanup()
stop_background_workers()
self._playback_router.shutdown()
self._main_tab.cleanup()
@@ -1524,6 +1658,7 @@ def run(self) -> None:
return
finally:
self._render_coordinator.cleanup()
+ self._export_coordinator.cleanup()
stop_background_workers()
self._playback_router.shutdown()
self._main_tab.cleanup()
diff --git a/src/sampletones_application/categories/context.py b/src/sampletones_application/categories/context.py
index 820d60a3a..ab5e1d2dd 100644
--- a/src/sampletones_application/categories/context.py
+++ b/src/sampletones_application/categories/context.py
@@ -3,11 +3,17 @@
from sampletones_application.categories.elements.global_ import ContextElements
from sampletones_application.categories.hierarchy import Page, Panel, TextType
from sampletones_application.categories.manager import LanguageManager
-from sampletones_core.constants.enums import GeneratorName
+from sampletones_core.constants.enums import ChannelName, GeneratorName
-CHANNEL_ELEMENTS: Final[Dict[GeneratorName, ContextElements]] = {
- GeneratorName.PULSE1: ContextElements.PULSE_1,
- GeneratorName.PULSE2: ContextElements.PULSE_2,
+CHANNEL_ELEMENTS: Final[Dict[ChannelName, ContextElements]] = {
+ ChannelName.PULSE1: ContextElements.PULSE_1,
+ ChannelName.PULSE2: ContextElements.PULSE_2,
+ ChannelName.TRIANGLE: ContextElements.TRIANGLE,
+ ChannelName.NOISE: ContextElements.NOISE,
+}
+
+GENERATOR_ELEMENTS: Final[Dict[GeneratorName, ContextElements]] = {
+ GeneratorName.PULSE: ContextElements.PULSE,
GeneratorName.TRIANGLE: ContextElements.TRIANGLE,
GeneratorName.NOISE: ContextElements.NOISE,
}
@@ -25,12 +31,12 @@ def context_text(
element comes from the same place.
Args:
- language_manager: The catalogue the words are read from.
+ language_manager: The catalog the words are read from.
text_type: The voice the element is read in.
element: The context element being read.
Returns:
- str: The words the catalogue holds for that element in that voice.
+ str: The words the catalog holds for that element in that voice.
"""
return language_manager[
Page.GLOBAL,
@@ -55,11 +61,24 @@ def context_label(
def channel_label(
language_manager: LanguageManager,
- generator: GeneratorName,
+ channel: ChannelName,
) -> str:
"""Resolves an NES channel's name, the words every display naming a channel prints.
The playback menu's mix, the samples menu's byte figures and anything else addressing a
channel read it from one entry, so a reader meets the same name for the same channel.
"""
- return context_label(language_manager, CHANNEL_ELEMENTS[generator])
+ return context_label(language_manager, CHANNEL_ELEMENTS[channel])
+
+
+def generator_label(
+ language_manager: LanguageManager,
+ generator_name: GeneratorName,
+) -> str:
+ """Resolves a generator's name, the words every display naming a generator prints.
+
+ A generator names the sound itself rather than one of the channels playing it, so the two
+ pulse channels share the one name. Reading it from a single entry keeps a generator called
+ the same thing wherever it is offered.
+ """
+ return context_label(language_manager, GENERATOR_ELEMENTS[generator_name])
diff --git a/src/sampletones_application/categories/elements/global_.py b/src/sampletones_application/categories/elements/global_.py
index c032172ea..e41ede4a9 100644
--- a/src/sampletones_application/categories/elements/global_.py
+++ b/src/sampletones_application/categories/elements/global_.py
@@ -18,20 +18,6 @@ class DialogElements(AbstractElement):
ADD_ANYWAY = "add_anyway"
-class TracebackElements(AbstractElement):
- COPY = "copy"
- SHOW = "show"
- HIDE = "hide"
-
-
-class TreeElements(AbstractElement):
- ROOT = "root"
- SEARCH = "search"
- FILTER = "filter"
- CLEAR_SEARCH = "clear_search"
- FAVORITES_ONLY = "favorites_only"
-
-
class ContextElements(AbstractElement):
PLAY = "play"
CUT = "cut"
@@ -47,6 +33,7 @@ class ContextElements(AbstractElement):
REPLACE_SAMPLE = "replace_sample"
LOCATE_ORIGINAL_AUDIO = "locate_original_audio"
TRIANGLE = "triangle"
+ PULSE = "pulse"
PULSE_1 = "pulse_1"
PULSE_2 = "pulse_2"
NOISE = "noise"
@@ -55,16 +42,6 @@ class ContextElements(AbstractElement):
SIZE_BYTES = "size_bytes"
-class NodeDetailElements(AbstractElement):
- SAMPLE_RATE = "detail_sample_rate"
- NES_FREQUENCY = "detail_nes_frequency"
- GENERATORS = "detail_generators"
- SPECTRUM_METHOD = "detail_spectrum_method"
- TRANSFORMATION_GAMMA = "detail_transformation_gamma"
- WINDOW_SIZE = "detail_window_size"
- CONFIGURATION = "detail_configuration"
-
-
class MenuElements(AbstractElement):
GROUP_FILE = "group_file"
ITEM_FILE_NEW_PROJECT = "item_file_new_project"
@@ -75,6 +52,7 @@ class MenuElements(AbstractElement):
GROUP_FILE_EXPORT = "group_file_export"
ITEM_FILE_EXPORT_FAMITRACKER = "item_file_export_famitracker"
ITEM_FILE_EXPORT_BITPHASE = "item_file_export_bitphase"
+ ITEM_FILE_EXPORT_NSF = "item_file_export_nsf"
ITEM_FILE_RENDER_SONG = "item_file_render_song"
ITEM_FILE_CLOSE_PROJECT = "item_file_close_project"
ITEM_FILE_EXIT = "item_file_exit"
@@ -94,6 +72,8 @@ class MenuElements(AbstractElement):
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"
+ ITEM_RECONSTRUCTION_EXPORT_INSTRUMENTS_NSF = "item_reconstruction_export_instruments_nsf"
+ GROUP_VOICE = "group_voice"
GROUP_PLAYBACK = "group_playback"
ITEM_PLAYBACK_PLAY = "item_playback_play"
ITEM_PLAYBACK_PAUSE = "item_playback_pause"
@@ -126,43 +106,6 @@ class MenuElements(AbstractElement):
TAB_SEQUENCER = "tab_sequencer"
-class StatusElements(AbstractElement):
- PATH = "path"
- NODE_RECONSTRUCTION_NO_AUTOPLAY = "node_reconstruction_no_autoplay"
- NODE_RECONSTRUCTION = "node_reconstruction"
- NODE_LIBRARY = "node_library"
- TREE_SEARCH = "tree_search"
- CLEAR_SEARCH = "clear_search"
- FAVORITES_ONLY = "favorites_only"
- INPUT = "input"
- COMBO = "combo"
- NODE_DIRECTORY = "node_directory"
- RETUNING_SAMPLES = "retuning_samples"
-
-
-class PlayerElements(AbstractElement):
- PLAY = "play"
- PAUSE = "pause"
- RESUME = "resume"
- STOP = "stop"
- AUDIO_PLAYBACK_ERROR = "audio_playback_error"
-
-
-class GraphElements(AbstractElement):
- WAVEFORM_ORIGINAL = "waveform_original"
- WAVEFORM_RECONSTRUCTION = "waveform_reconstruction"
- WAVEFORM_TIME_AXIS = "waveform_time_axis"
- WAVEFORM_AMPLITUDE_AXIS = "waveform_amplitude_axis"
- WAVEFORM_SAMPLE_NAME = "waveform_sample_name"
- SPECTRUM_X_AXIS = "spectrum_x_axis"
- SPECTRUM_FREQUENCY_AXIS = "spectrum_frequency_axis"
- SPECTRUM_NAME = "spectrum_name"
- BAR_DISPLAY = "bar_display"
- SPECTRUM_NAVIGATION = "spectrum_navigation"
- WAVEFORM_NAVIGATION = "waveform_navigation"
- WAVEFORM_REGENERATING = "waveform_regenerating"
-
-
class GlobalMessageElements(AbstractElement):
TREE_NO_RESULTS = "tree_no_results"
TREE_NO_FAVORITES = "tree_no_favorites"
@@ -182,6 +125,8 @@ class GlobalMessageElements(AbstractElement):
PROJECT_EXPORT_FAILED = "project_export_failed"
BITPHASE_PROJECT_EXPORTED_SUCCESSFULLY = "bitphase_project_exported_successfully"
BITPHASE_PROJECT_EXPORT_FAILED = "bitphase_project_export_failed"
+ NSF_PROJECT_EXPORTED_SUCCESSFULLY = "nsf_project_exported_successfully"
+ NSF_PROJECT_EXPORT_FAILED = "nsf_project_export_failed"
NEW_UNSAVED_PROJECT = "new_unsaved_project"
OPEN_UNSAVED_PROJECT = "open_unsaved_project"
CLOSE_UNSAVED_PROJECT = "close_unsaved_project"
@@ -198,7 +143,7 @@ class GlobalMessageElements(AbstractElement):
CONFIGURATION_RECOVERY_PATH_PREFIX = "configuration_recovery_path_prefix"
AUDIO_PLAYBACK_ERROR = "audio_playback_error"
NO_PROJECT_OPEN = "no_project_open"
- REMOVE_SAMPLE = "remove_sample"
+ REMOVE_VOICE = "remove_voice"
CHANGE_NES_FREQUENCY = "change_nes_frequency"
FREQUENCY_MISMATCH = "frequency_mismatch"
OPERATION_IN_PROGRESS = "operation_in_progress"
@@ -224,12 +169,13 @@ class GlobalDialogTitleElements(AbstractElement):
PROJECT_SAVED = "project_saved"
EXPORT_MODULE = "export_module"
EXPORT_BITPHASE_PROJECT = "export_bitphase_project"
+ EXPORT_NSF_PROJECT = "export_nsf_project"
PROJECT_EXPORTED = "project_exported"
NEW_UNSAVED_PROJECT = "new_unsaved_project"
OPEN_UNSAVED_PROJECT = "open_unsaved_project"
CLOSE_UNSAVED_PROJECT = "close_unsaved_project"
NO_PROJECT_OPEN = "no_project_open"
- REMOVE_SAMPLE = "remove_sample"
+ REMOVE_VOICE = "remove_voice"
CHANGE_NES_FREQUENCY = "change_nes_frequency"
FREQUENCY_MISMATCH = "frequency_mismatch"
ABOUT = "about"
@@ -242,24 +188,7 @@ class FileFilterElements(AbstractElement):
FAMITRACKER_INSTRUMENT = "famitracker_instrument"
BITPHASE_PROJECT = "bitphase_project"
BITPHASE_PRESET = "bitphase_preset"
+ NSF = "nsf"
CONFIG = "config"
AUDIO = "audio"
WAVE = "wave"
-
-
-class GlobalTemplateElements(AbstractElement):
- TIME_ESTIMATION = "time_estimation"
- CONFIGURATION_RECOVERY_INTRO = "configuration_recovery_intro"
- FPS = "fps"
- ABOUT_AUTHOR = "about_author"
- EXPAND = "expand"
- COLLAPSE = "collapse"
- ON = "on"
- OFF = "off"
-
-
-class GlobalPitchElements(AbstractElement):
- PITCH_NAME = "pitch_name"
- PERIOD_NAME = "period_name"
- PITCH_EXAMPLE = "pitch_example"
- PERIOD_EXAMPLE = "period_example"
diff --git a/src/sampletones_application/categories/elements/instructions.py b/src/sampletones_application/categories/elements/instructions.py
index 617d4ff8e..feb7b13d1 100644
--- a/src/sampletones_application/categories/elements/instructions.py
+++ b/src/sampletones_application/categories/elements/instructions.py
@@ -23,7 +23,7 @@ class InstructionsLibraryElements(AbstractElement):
STATUS_SAVING = "status_saving"
STATUS_GENERATION_SUCCESS = "status_generation_success"
STATUS_WINDOW_NOT_AVAILABLE = "status_window_not_available"
- STATUS_GENERATION_CANCELLED = "status_generation_cancelled"
+ STATUS_GENERATION_CANCELED = "status_generation_canceled"
STATUS_GENERATION_FAILED = "status_generation_failed"
STATUS_FILE_NOT_FOUND = "status_file_not_found"
STATUS_FILE_LOAD_ERROR = "status_file_load_error"
@@ -41,36 +41,3 @@ class InstructionsLibraryElements(AbstractElement):
LIBRARY_EXISTS_TEMPLATE = "library_exists_template"
LIBRARY_LOADED_TEMPLATE = "library_loaded_template"
INCOMPATIBLE_VERSION_TEMPLATE = "incompatible_version_template"
-
-
-class InstructionPanelElements(AbstractElement):
- WAVEFORM_LABEL = "waveform_label"
- SPECTRUM_LABEL = "spectrum_label"
-
-
-class InstructionsDetailsElements(AbstractElement):
- DETAILS_TEXT = "details_text"
- PARAMETERS_TEXT = "parameters_text"
- GENERAL_TEXT = "general_text"
- CELL_NES_FREQUENCY = "cell_nes_frequency"
- CELL_GENERATOR = "cell_generator"
- CELL_NAME = "cell_name"
- CELL_FREQUENCY = "cell_frequency"
- CELL_NO_FREQUENCY = "cell_no_frequency"
- CELL_SAMPLE_LENGTH = "cell_sample_length"
- CELL_SAMPLES_SUFFIX = "cell_samples_suffix"
- WINDOW_PULSE_PITCH = "window_pulse_pitch"
- WINDOW_PULSE_VOLUME = "window_pulse_volume"
- WINDOW_PULSE_DUTY_CYCLE = "window_pulse_duty_cycle"
- WINDOW_NOISE_PERIOD = "window_noise_period"
- WINDOW_NOISE_VOLUME = "window_noise_volume"
- WINDOW_NOISE_SHORT = "window_noise_short"
- WINDOW_TRIANGLE_PITCH = "window_triangle_pitch"
- FREQUENCY_TEMPLATE = "frequency_template"
- PITCH_TEMPLATE = "pitch_template"
- PERIOD_TEMPLATE = "period_template"
- DUTY_CYCLE_TEMPLATE = "duty_cycle_template"
- PITCH_TOOLTIP_TEMPLATE = "pitch_tooltip_template"
- STATUS_INPUT_PITCH = "status_input_pitch"
- STATUS_INPUT_PERIOD = "status_input_period"
- NO_INSTRUCTION_SELECTED = "no_instruction_selected"
diff --git a/src/sampletones_application/categories/elements/main.py b/src/sampletones_application/categories/elements/main.py
index 496e1b82d..ed0fdf399 100644
--- a/src/sampletones_application/categories/elements/main.py
+++ b/src/sampletones_application/categories/elements/main.py
@@ -1,87 +1,12 @@
from sampletones_application.categories.abstract import AbstractElement
-class ExplorerElements(AbstractElement):
- SECTION = "section"
- REFRESH_BUTTON = "refresh_button"
- CONTEXT_LOAD_RECONSTRUCTION = "context_load_reconstruction"
- CONTEXT_LOAD_LIBRARY = "context_load_library"
- CONTEXT_RECONSTRUCT_FILE = "context_reconstruct_file"
- CONTEXT_RECONSTRUCT_DIRECTORY = "context_reconstruct_directory"
- CONTEXT_SET_LIBRARY_DIRECTORY = "context_set_library_directory"
- CONTEXT_SET_OUTPUT_DIRECTORY = "context_set_output_directory"
- STATUS_REFRESH = "status_refresh"
- STATUS_NODE_AUDIO_NO_AUTOPLAY = "status_node_audio_no_autoplay"
- STATUS_NODE_AUDIO = "status_node_audio"
- STATUS_NODE_LIBRARY = "status_node_library"
- CONVERTER_RUNNING_MSG = "converter_running_msg"
- CONVERTER_RUNNING_DIALOG = "converter_running_dialog"
-
-
-class ConfigPanelElements(AbstractElement):
- SECTION = "section"
- SECTION_LIBRARY = "section_library"
- CHECKBOX_NORMALIZE = "checkbox_normalize"
- CHECKBOX_QUANTIZE = "checkbox_quantize"
- INPUT_SAMPLE_RATE = "input_sample_rate"
- INPUT_NES_FREQUENCY = "input_nes_frequency"
- TOOLTIP_NORMALIZE = "tooltip_normalize"
- TOOLTIP_QUANTIZE = "tooltip_quantize"
- TOOLTIP_SAMPLE_RATE = "tooltip_sample_rate"
- TOOLTIP_NES_FREQUENCY = "tooltip_nes_frequency"
-
-
-class ReconstructorElements(AbstractElement):
- SECTION_GENERATORS = "section_generators"
- SECTION_SETTINGS = "section_settings"
- SLIDER_DRIVE = "slider_drive"
- TOOLTIP_DRIVE = "tooltip_drive"
-
-
-class ConverterElements(AbstractElement):
- SECTION = "section"
- CLOSE_BUTTON = "close_button"
- CANCEL_BUTTON = "cancel_button"
- LOAD_BUTTON = "load_button"
- OPEN_BUTTON = "open_button"
- STOP_BUTTON = "stop_button"
- CONTINUE_BUTTON = "continue_button"
- CONVERT_SAMPLE_BUTTON = "convert_sample_button"
- CONVERT_DIRECTORY_BUTTON = "convert_directory_button"
- STATUS_CONVERT = "status_convert"
- STATUS_CANCEL = "status_cancel"
- STATUS_ERROR = "status_error"
- STATUS_RECONSTRUCTION_COMPLETED = "status_reconstruction_completed"
- STATUS_NO_FILES = "status_no_files"
- STATUS_NO_GENERATORS = "status_no_generators"
- STATUS_IDLE = "status_idle"
- STATUS_WAITING = "status_waiting"
- STATUS_GENERATING_LIBRARY = "status_generating_library"
- STATUS_CANCELLING = "status_cancelling"
- STATUS_CANCELLED = "status_cancelled"
- STATUS_INPUT_LABEL = "status_input_label"
- STATUS_OUTPUT_LABEL = "status_output_label"
- STATUS_EMPTY_HINT = "status_empty_hint"
- PROGRESS_DIALOG = "progress_dialog"
- LOAD_DIALOG = "load_dialog"
- LOAD_FILE_PROMPT = "load_file_prompt"
- LOAD_DIRECTORY_PROMPT = "load_directory_prompt"
- CANCEL_DIALOG = "cancel_dialog"
- CANCEL_PROMPT = "cancel_prompt"
- PROGRESS_TEMPLATE = "progress_template"
- CONVERT_LABEL_TEMPLATE = "convert_label_template"
-
-
-class AdvancedElements(AbstractElement):
- SECTION = "section"
- SECTION_METHOD = "section_method"
- SELECT_OUTPUT_DIRECTORY = "select_output_directory"
- SELECT_LIBRARY_DIRECTORY = "select_library_directory"
- COMBO_SPECTRUM_METHOD = "combo_spectrum_method"
- SLIDER_TRANSFORMATION_GAMMA = "slider_transformation_gamma"
- INPUT_MAX_WORKERS = "input_max_workers"
- TOOLTIP_SPECTRUM_METHOD = "tooltip_spectrum_method"
- TOOLTIP_TRANSFORMATION_GAMMA = "tooltip_transformation_gamma"
- TOOLTIP_MAX_WORKERS = "tooltip_max_workers"
- STATUS_SELECT_LIBRARY = "status_select_library"
- STATUS_SELECT_OUTPUT = "status_select_output"
+class ConverterStemMoveElements(AbstractElement):
+ """The moves a gathered recording can make, as the row's menu names them."""
+
+ CONTEXT_MOVE_UP = "context_move_up"
+ CONTEXT_MOVE_DOWN = "context_move_down"
+ CONTEXT_JOIN_ABOVE = "context_join_above"
+ CONTEXT_JOIN_BELOW = "context_join_below"
+ CONTEXT_ISOLATE = "context_isolate"
+ CONTEXT_REMOVE_STEM = "context_remove_stem"
diff --git a/src/sampletones_application/categories/elements/reconstructions.py b/src/sampletones_application/categories/elements/reconstructions.py
deleted file mode 100644
index fb60c6091..000000000
--- a/src/sampletones_application/categories/elements/reconstructions.py
+++ /dev/null
@@ -1,72 +0,0 @@
-from sampletones_application.categories.abstract import AbstractElement
-
-
-class ReconstructionsBrowserElements(AbstractElement):
- REFRESH_BUTTON = "refresh_button"
- STATUS_REFRESH = "status_refresh"
- RECONSTRUCTIONS_TREE = "reconstructions_tree"
- CONTEXT_LOAD_RECONSTRUCTION = "context_load_reconstruction"
- CONTEXT_REMOVE_RECONSTRUCTION = "context_remove_reconstruction"
- CONTEXT_REMOVE_DIRECTORY = "context_remove_directory"
- FILE_NOT_FOUND = "file_not_found"
- AUDIO_FILE_NOT_FOUND = "audio_file_not_found"
- LOAD_ERROR = "load_error"
- INVALID_VALUES = "invalid_values"
- INVALID_FILE = "invalid_file"
- DESERIALIZATION_ERROR = "deserialization_error"
- LOAD_RECONSTRUCTION_DIALOG = "load_reconstruction_dialog"
- REMOVE_RECONSTRUCTION_DIALOG = "remove_reconstruction_dialog"
- REMOVE_RECONSTRUCTION_MESSAGE = "remove_reconstruction_message"
- REMOVE_DIRECTORY_DIALOG = "remove_directory_dialog"
- REMOVE_DIRECTORY_MESSAGE = "remove_directory_message"
- INCOMPATIBLE_VERSION_TEMPLATE = "incompatible_version_template"
-
-
-class ReconstructionPanelElements(AbstractElement):
- AUDIO_SOURCE_LABEL = "audio_source_label"
- AUTOSCALE_CHECKBOX = "autoscale_checkbox"
- RECONSTRUCTION_FILE_LABEL = "reconstruction_file_label"
- ORIGINAL_AUDIO_LABEL = "original_audio_label"
- PATH_NOT_FOUND = "path_not_found"
- PATH_NOT_APPLICABLE = "path_not_applicable"
- ORIGINAL_AUDIO_RADIO = "original_audio_radio"
- RECONSTRUCTION_RADIO = "reconstruction_radio"
- WAVEFORM_LABEL = "waveform_label"
- AUTOSCALE_TOOLTIP = "autoscale_tooltip"
- LOCATE_AUDIO_FAILED = "locate_audio_failed"
- EXPORT_WAV_SUCCESS = "export_wav_success"
- EXPORT_WAV_FAILED = "export_wav_failed"
-
-
-class ReconstructionsInstrumentsElements(AbstractElement):
- SECTION = "section"
- EXPORT_INSTRUMENT_BUTTON = "export_instrument_button"
- COPY_BUTTON = "copy_button"
- PITCH_LABEL = "pitch_label"
- HI_PITCH_LABEL = "hi_pitch_label"
- VOLUME_LABEL = "volume_label"
- ARPEGGIO_LABEL = "arpeggio_label"
- DUTY_CYCLE_LABEL = "duty_cycle_label"
- INITIAL_PERIOD = "initial_period"
- INITIAL_PITCH = "initial_pitch"
- STATUS_INPUT_PITCH = "status_input_pitch"
- STATUS_INPUT_PERIOD = "status_input_period"
- STATUS_BAR = "status_bar"
- STATUS_SEQUENCE = "status_sequence"
- STATUS_SEQUENCE_TOO_LONG = "status_sequence_too_long"
- STATUS_COPY_SEQUENCE = "status_copy_sequence"
- STATUS_GENERATOR_TOGGLE = "status_generator_toggle"
- STATUS_GENERATOR_NOT_AVAILABLE = "status_generator_not_available"
- STATUS_EXPORT_INSTRUMENT = "status_export_instrument"
- EXPORT_INSTRUMENT_SUCCESS = "export_instrument_success"
- EXPORT_INSTRUMENTS_SUCCESS = "export_instruments_success"
- EXPORT_INSTRUMENT_TRUNCATED = "export_instrument_truncated"
- EXPORT_INSTRUMENTS_TRUNCATED = "export_instruments_truncated"
- EXPORT_INSTRUMENT_FAILED = "export_instrument_failed"
- EXPORT_INSTRUMENTS_FAILED = "export_instruments_failed"
- EXPORT_STATUS_DIALOG = "export_status_dialog"
- NOT_LOADED_DIALOG = "not_loaded_dialog"
- EXPORT_WAV_DIALOG = "export_wav_dialog"
- EXPORT_INSTRUMENT_DIALOG = "export_instrument_dialog"
- EXPORT_INSTRUMENTS_DIALOG = "export_instruments_dialog"
- INITIAL_PITCH_TOOLTIP_TEMPLATE = "initial_pitch_tooltip_template"
diff --git a/src/sampletones_application/categories/elements/sequencer.py b/src/sampletones_application/categories/elements/sequencer.py
index 448a0264b..ac29f18e9 100644
--- a/src/sampletones_application/categories/elements/sequencer.py
+++ b/src/sampletones_application/categories/elements/sequencer.py
@@ -1,25 +1,9 @@
from sampletones_application.categories.abstract import AbstractElement
-class SequencerBrowserElements(AbstractElement):
- REFRESH_BUTTON = "refresh_button"
- STATUS_REFRESH = "status_refresh"
- RECONSTRUCTIONS_TREE = "reconstructions_tree"
- FILE_NOT_FOUND = "file_not_found"
- LOAD_ERROR = "load_error"
- LOAD_RECONSTRUCTION_DIALOG = "load_reconstruction_dialog"
-
-
-class SequencerModuleElements(AbstractElement):
- MODULE_OPTIONS = "module_options"
- NES_FREQUENCY = "nes_frequency"
- ROWS = "rows"
- TEMPO = "tempo"
- SPEED = "speed"
-
-
class SequencerTrackerElements(AbstractElement):
TRACKER_TEXT = "tracker_text"
+ OCTAVE = "octave"
COLUMN_ROW = "column_row"
COLUMN_SAMPLE = "column_sample"
COLUMN_PULSE_1 = "column_pulse_1"
@@ -34,8 +18,8 @@ class SequencerTrackerElements(AbstractElement):
CONTEXT_SELECT_COLUMN = "context_select_column"
CONTEXT_SELECT_SUBCOLUMN = "context_select_subcolumn"
CONTEXT_NOTE_OFF = "context_note_off"
- CONTEXT_SET_INSTRUMENT = "context_set_instrument"
- CONTEXT_NO_SAMPLES = "context_no_samples"
+ CONTEXT_SET_VOICE = "context_set_voice"
+ CONTEXT_NO_VOICES = "context_no_voices"
CONTEXT_CLEAR_SUBCOLUMN = "context_clear_subcolumn"
CONTEXT_CLEAR_CELL = "context_clear_cell"
CONTEXT_CLEAR_ROW = "context_clear_row"
@@ -84,11 +68,16 @@ class SequencerOrderElements(AbstractElement):
CONTEXT_UNMUTE_ALL = "context_unmute_all"
-class SequencerInstrumentsElements(AbstractElement):
- INSTRUMENTS_TEXT = "instruments_text"
+class SequencerVoicesElements(AbstractElement):
+ VOICES_TEXT = "voices_text"
+ NEW_INSTRUMENT = "new_instrument"
+ ADD_SAMPLE = "add_sample"
+ IMPORT_INSTRUMENT = "import_instrument"
+ KIND_SAMPLE = "kind_sample"
+ KIND_INSTRUMENT = "kind_instrument"
+ COLUMN_KIND = "column_kind"
COLUMN_ID = "column_id"
COLUMN_NAME = "column_name"
- COLUMN_LOOP = "column_loop"
CONTEXT_EDIT = "context_edit"
CONTEXT_RENAME = "context_rename"
CONTEXT_DUPLICATE = "context_duplicate"
@@ -97,6 +86,11 @@ class SequencerInstrumentsElements(AbstractElement):
CONTEXT_MOVE_DOWN = "context_move_down"
CONTEXT_MOVE_TOP = "context_move_top"
CONTEXT_MOVE_BOTTOM = "context_move_bottom"
+ CONTEXT_INSTRUMENT_FROM = "context_instrument_from"
+ CONTEXT_EXPORT_INSTRUMENT = "context_export_instrument"
+ OMISSION_CUMULATIVE_BEND = "omission_cumulative_bend"
+ OMISSION_RELEASE_POINT = "omission_release_point"
+ OMISSION_ARPEGGIO_MODE = "omission_arpeggio_mode"
class SequencerHistoryElements(AbstractElement):
@@ -106,5 +100,3 @@ class SequencerHistoryElements(AbstractElement):
STATUS_UNDO = "status_undo"
STATUS_REDO = "status_redo"
EMPTY = "empty"
- LOOP_ON = "loop_on"
- LOOP_OFF = "loop_off"
diff --git a/src/sampletones_application/categories/elements/settings.py b/src/sampletones_application/categories/elements/settings.py
index ec4b1af23..53fe0d432 100644
--- a/src/sampletones_application/categories/elements/settings.py
+++ b/src/sampletones_application/categories/elements/settings.py
@@ -1,20 +1,6 @@
from sampletones_application.categories.abstract import AbstractElement
-class AudioSettingsElements(AbstractElement):
- OUTPUT_DEVICE = "output_device"
- DEVICE_LABEL = "device_label"
- SAMPLE_RATE = "sample_rate"
- SAMPLE_RATE_LABEL = "sample_rate_label"
- BUFFER_SIZE = "buffer_size"
- MASTER_GAIN = "master_gain"
- MASTER_GAIN_DB = "master_gain_db"
- MASTER_GAIN_SILENT = "master_gain_silent"
- APPLY_BUTTON = "apply_button"
- REFRESH_DEVICES_BUTTON = "refresh_devices_button"
- WINDOW_TITLE = "window_title"
-
-
class ProjectPropertiesElements(AbstractElement):
WINDOW_TITLE = "window_title"
TITLE = "title"
@@ -41,6 +27,7 @@ class KeybindingActionElements(AbstractElement):
PROJECT_PROPERTIES = "project_properties"
EXPORT_PROJECT_FAMITRACKER = "export_project_famitracker"
EXPORT_PROJECT_BITPHASE = "export_project_bitphase"
+ EXPORT_PROJECT_NSF = "export_project_nsf"
RENDER_SONG = "render_song"
CLOSE_PROJECT = "close_project"
EXIT = "exit"
@@ -57,7 +44,11 @@ class KeybindingActionElements(AbstractElement):
EXPORT_RECONSTRUCTION_WAV = "export_reconstruction_wav"
EXPORT_INSTRUMENTS_FAMITRACKER = "export_instruments_famitracker"
EXPORT_INSTRUMENTS_BITPHASE_PRESET = "export_instruments_bitphase_preset"
+ EXPORT_INSTRUMENTS_NSF = "export_instruments_nsf"
ADD_RECONSTRUCTION_TO_SEQUENCER = "add_reconstruction_to_sequencer"
+ NEW_INSTRUMENT = "new_instrument"
+ ADD_SAMPLE_FROM_FILE = "add_sample_from_file"
+ IMPORT_INSTRUMENT = "import_instrument"
OPEN_RECONSTRUCTION_IN_EXPLORER = "open_reconstruction_in_explorer"
LOCATE_ORIGINAL_AUDIO = "locate_original_audio"
PLAY = "play"
@@ -155,13 +146,13 @@ class KeybindingActionElements(AbstractElement):
TRACKER_CANCEL_ENTRY = "tracker_cancel_entry"
TRACKER_PLAY_FROM_ROW = "tracker_play_from_row"
- SAMPLES_RENAME_SAMPLE = "samples_rename_sample"
- SAMPLES_REMOVE_SAMPLE = "samples_remove_sample"
- SAMPLES_MOVE_SAMPLE_UP = "samples_move_sample_up"
- SAMPLES_MOVE_SAMPLE_DOWN = "samples_move_sample_down"
- SAMPLES_MOVE_SAMPLE_TO_TOP = "samples_move_sample_to_top"
- SAMPLES_MOVE_SAMPLE_TO_BOTTOM = "samples_move_sample_to_bottom"
- SAMPLES_CANCEL_RENAME = "samples_cancel_rename"
+ VOICES_RENAME_VOICE = "voices_rename_voice"
+ VOICES_REMOVE_VOICE = "voices_remove_voice"
+ VOICES_MOVE_VOICE_UP = "voices_move_voice_up"
+ VOICES_MOVE_VOICE_DOWN = "voices_move_voice_down"
+ VOICES_MOVE_VOICE_TO_TOP = "voices_move_voice_to_top"
+ VOICES_MOVE_VOICE_TO_BOTTOM = "voices_move_voice_to_bottom"
+ VOICES_CANCEL_RENAME = "voices_cancel_rename"
class KeybindingCategoryElements(AbstractElement):
@@ -170,7 +161,7 @@ class KeybindingCategoryElements(AbstractElement):
APPLICATION = "application"
ORDER = "order"
TRACKER = "tracker"
- SAMPLES = "samples"
+ VOICES = "voices"
class KeybindingsElements(AbstractElement):
diff --git a/src/sampletones_application/categories/exports.py b/src/sampletones_application/categories/exports.py
new file mode 100644
index 000000000..4b2399d51
--- /dev/null
+++ b/src/sampletones_application/categories/exports.py
@@ -0,0 +1,76 @@
+from dataclasses import dataclass
+from typing import Dict, Final, Tuple
+
+from sampletones_application.categories.elements.global_ import (
+ FileFilterElements,
+ GlobalDialogTitleElements,
+ GlobalMessageElements,
+ MenuElements,
+)
+from sampletones_core.exports.format import ExportFormat
+
+
+@dataclass(frozen=True)
+class ExportProjectElements:
+ """Which texts one export 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 program 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
+
+
+EXPORT_PROJECT_ELEMENTS: Final[Dict[ExportFormat, ExportProjectElements]] = {
+ ExportFormat.FAMITRACKER: ExportProjectElements(
+ dialog_title=GlobalDialogTitleElements.EXPORT_MODULE,
+ filter_name=FileFilterElements.MODULE,
+ exported_message=GlobalMessageElements.PROJECT_EXPORTED_SUCCESSFULLY,
+ export_failed_message=GlobalMessageElements.PROJECT_EXPORT_FAILED,
+ ),
+ ExportFormat.BITPHASE: ExportProjectElements(
+ 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,
+ ),
+ ExportFormat.NSF: ExportProjectElements(
+ dialog_title=GlobalDialogTitleElements.EXPORT_NSF_PROJECT,
+ filter_name=FileFilterElements.NSF,
+ exported_message=GlobalMessageElements.NSF_PROJECT_EXPORTED_SUCCESSFULLY,
+ export_failed_message=GlobalMessageElements.NSF_PROJECT_EXPORT_FAILED,
+ ),
+}
+
+EXPORT_PROJECT_MENU_LABELS: Final[Dict[ExportFormat, MenuElements]] = {
+ ExportFormat.FAMITRACKER: MenuElements.ITEM_FILE_EXPORT_FAMITRACKER,
+ ExportFormat.BITPHASE: MenuElements.ITEM_FILE_EXPORT_BITPHASE,
+ ExportFormat.NSF: MenuElements.ITEM_FILE_EXPORT_NSF,
+}
+
+INSTRUMENT_EXPORT_FORMATS: Final[Tuple[ExportFormat, ...]] = (
+ ExportFormat.FAMITRACKER,
+ ExportFormat.BITPHASE_PRESET,
+ ExportFormat.NSF,
+)
+
+EXPORT_INSTRUMENT_FILTERS: Final[Dict[ExportFormat, FileFilterElements]] = {
+ ExportFormat.FAMITRACKER: FileFilterElements.FAMITRACKER_INSTRUMENT,
+ ExportFormat.BITPHASE_PRESET: FileFilterElements.BITPHASE_PRESET,
+ ExportFormat.NSF: FileFilterElements.NSF,
+}
+
+EXPORT_SAMPLE_MENU_LABELS: Final[Dict[ExportFormat, MenuElements]] = {
+ ExportFormat.FAMITRACKER: MenuElements.ITEM_RECONSTRUCTION_EXPORT_INSTRUMENTS_FAMITRACKER,
+ ExportFormat.BITPHASE_PRESET: MenuElements.ITEM_RECONSTRUCTION_EXPORT_INSTRUMENTS_BITPHASE_PRESET,
+ ExportFormat.NSF: MenuElements.ITEM_RECONSTRUCTION_EXPORT_INSTRUMENTS_NSF,
+}
diff --git a/src/sampletones_application/categories/hierarchy.py b/src/sampletones_application/categories/hierarchy.py
index 8a2689e60..a0386ac4b 100644
--- a/src/sampletones_application/categories/hierarchy.py
+++ b/src/sampletones_application/categories/hierarchy.py
@@ -62,6 +62,7 @@ class Panel(StrEnum):
TRACEBACK = auto()
CONTEXT = auto()
STATUS = auto()
+ STEMS = auto()
GRAPH = auto()
PITCH = auto()
@@ -84,6 +85,7 @@ class Panel(StrEnum):
TRACKER = auto()
ORDER = auto()
MODULE = auto()
+ VOICES = auto()
INSTRUMENTS = auto()
HISTORY = auto()
@@ -97,3 +99,4 @@ class Panel(StrEnum):
KEYBINDINGS = auto()
PROPERTIES = auto()
RENDER = auto()
+ EXPORT = auto()
diff --git a/src/sampletones_application/categories/instrument.py b/src/sampletones_application/categories/instrument.py
new file mode 100644
index 000000000..3a92804ac
--- /dev/null
+++ b/src/sampletones_application/categories/instrument.py
@@ -0,0 +1,103 @@
+from dataclasses import dataclass
+from typing import Dict, Final, Optional, Self, Tuple
+
+from sampletones_application.categories.elements.sequencer import (
+ SequencerVoicesElements,
+)
+from sampletones_application.categories.hierarchy import Page, Panel, TextType
+from sampletones_application.categories.manager import LanguageManager
+from sampletones_core.formats.famitracker.voice import InstrumentOmission
+
+OMISSION_ELEMENTS: Final[Dict[InstrumentOmission, SequencerVoicesElements]] = {
+ InstrumentOmission.CUMULATIVE_BEND: SequencerVoicesElements.OMISSION_CUMULATIVE_BEND,
+ InstrumentOmission.RELEASE_POINT: SequencerVoicesElements.OMISSION_RELEASE_POINT,
+ InstrumentOmission.ARPEGGIO_MODE: SequencerVoicesElements.OMISSION_ARPEGGIO_MODE,
+}
+
+OMISSION_BULLET: Final[str] = " - "
+
+
+def omission_label(
+ language_manager: LanguageManager,
+ element: SequencerVoicesElements,
+) -> str:
+ """Resolves the words one dimension of a tracker instrument is named in."""
+ return language_manager[
+ Page.SEQUENCER,
+ Panel.VOICES,
+ TextType.LABEL,
+ element,
+ ]
+
+
+@dataclass(frozen=True)
+class InstrumentImportMessages:
+ """The words a read instrument file is reported in.
+
+ A ``.fti`` states a channel's whole instrument, and a voice takes every envelope it holds.
+ Whatever the file states past them stays in the file, so the import names it in the reader's
+ own words as the voice arrives.
+
+ Attributes:
+ title: Title of the dialog reporting a finished import.
+ template: The opening lines, naming the voice that arrived.
+ omissions: The words each dimension a file states past the voice is named in.
+ """
+
+ title: str
+ template: str
+ omissions: Dict[InstrumentOmission, str]
+
+ @classmethod
+ def build(cls, language_manager: LanguageManager) -> Self:
+ """Resolves every word the import report prints.
+
+ Args:
+ language_manager: The catalog the words are read from.
+
+ Returns:
+ Self: The bundle the import handler reads.
+ """
+ return cls(
+ title=language_manager["sequencer.voices.title.instrument_imported"],
+ template=language_manager["sequencer.voices.template.instrument_omissions"],
+ omissions={
+ omission: omission_label(
+ language_manager,
+ element,
+ )
+ for omission, element in OMISSION_ELEMENTS.items()
+ },
+ )
+
+ def notice(
+ self,
+ name: str,
+ omissions: Tuple[InstrumentOmission, ...],
+ ) -> Optional[str]:
+ """Phrases what an instrument file held beyond the voice it made.
+
+ The dimensions are named in the order the reader states them, so one wording describes a
+ file however many of them it carries.
+
+ Args:
+ name: The name the voice arrived under.
+ omissions: What the instrument file stated past the voice's envelopes.
+
+ Returns:
+ Optional[str]: The report the import dialog prints, or ``None`` where the file
+ stated the voice alone.
+ """
+ if not omissions:
+ return None
+
+ return "\n".join(
+ (
+ self.template.format(name=name),
+ self._listed(omissions),
+ ),
+ )
+
+ def _listed(self, omissions: Tuple[InstrumentOmission, ...]) -> str:
+ """The dimensions a file states past the voice, one to a line under its own mark."""
+ return "\n".join(f"{OMISSION_BULLET}{self.omissions[omission]}" for omission in omissions)
diff --git a/src/sampletones_application/categories/trackers.py b/src/sampletones_application/categories/trackers.py
deleted file mode 100644
index be1a789c8..000000000
--- a/src/sampletones_application/categories/trackers.py
+++ /dev/null
@@ -1,66 +0,0 @@
-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/config/deployment/deployment.py b/src/sampletones_application/config/deployment/deployment.py
index c86e23821..a89c411f8 100644
--- a/src/sampletones_application/config/deployment/deployment.py
+++ b/src/sampletones_application/config/deployment/deployment.py
@@ -19,11 +19,16 @@ class DeploymentConfig(BaseModel, frozen=True):
the history self-heals by recording the mutation as its own entry.
``log_level`` sets the verbosity of the application logger at startup.
- Every field is required, and the shipped ``deployment.yaml`` supplies the
- authoritative baseline for each one. The ``SAMPLETONES_LOG_LEVEL`` and
- ``SAMPLETONES_STRICT_HISTORY`` environment variables override individual
- fields when set, letting a development run raise verbosity or enable strict
- history while the shipped file keeps user builds quiet and self-healing.
+ Every field is required, and ``deployment.yaml`` supplies the baseline. It carries the
+ development values — verbose logging, and strict history so a missing transaction is
+ reported the moment it happens rather than healed in silence — since the source tree is
+ where an edit is written and where that report is worth having. A release build injects the
+ user-facing values through ``scripts/release_env_hook.py``, so a shipped artifact is quiet
+ and self-healing whatever the tree it was built from said.
+
+ The ``SAMPLETONES_LOG_LEVEL`` and ``SAMPLETONES_STRICT_HISTORY`` environment variables set
+ either field, which is how the release hook states its values and how a run of either kind
+ takes the other's.
"""
log_level: LogLevel
diff --git a/src/sampletones_application/config/managers/application.py b/src/sampletones_application/config/managers/application.py
index 20f912569..2e911a106 100644
--- a/src/sampletones_application/config/managers/application.py
+++ b/src/sampletones_application/config/managers/application.py
@@ -153,6 +153,13 @@ def follow_mode(self) -> FollowMode:
def set_follow_mode(self, value: FollowMode) -> None:
self.config.playback.follow_mode = value
+ @property
+ def octave(self) -> int:
+ return self.config.tracker.octave
+
+ def set_octave(self, value: int) -> None:
+ self.config.tracker.octave = value
+
@property
def loop_song(self) -> bool:
return self.config.playback.loop_song
diff --git a/src/sampletones_application/config/managers/config.py b/src/sampletones_application/config/managers/config.py
index cd7038317..0400f2bc2 100644
--- a/src/sampletones_application/config/managers/config.py
+++ b/src/sampletones_application/config/managers/config.py
@@ -117,7 +117,7 @@ def apply_generation_settings(
new_generation = self.config.generation.model_copy(
update={
"drive": update.drive,
- "generators": update.generators,
+ "channels": update.channels,
}
)
self.config = self.config.model_copy(
diff --git a/src/sampletones_application/config/managers/session.py b/src/sampletones_application/config/managers/session.py
index c5ee6fa1b..d19ab68b1 100644
--- a/src/sampletones_application/config/managers/session.py
+++ b/src/sampletones_application/config/managers/session.py
@@ -87,6 +87,9 @@ def set_follow_mode(self, value: FollowMode) -> None:
def set_loop_song(self, value: bool) -> None:
self._config_manager.set_loop_song(value)
+ def set_octave(self, value: int) -> None:
+ self._config_manager.set_octave(value)
+
def toggle_favorite(self, path: Path) -> None:
self._config_manager.toggle_favorite(path)
@@ -263,6 +266,10 @@ def auto_expand_favorite_directories(self) -> bool:
def follow_mode(self) -> FollowMode:
return self._config_manager.follow_mode
+ @property
+ def octave(self) -> int:
+ return self._config_manager.octave
+
@property
def loop_song(self) -> bool:
return self._config_manager.loop_song
diff --git a/src/sampletones_application/config/session/application/config.py b/src/sampletones_application/config/session/application/config.py
index 0c3fc0c9f..97412fd99 100644
--- a/src/sampletones_application/config/session/application/config.py
+++ b/src/sampletones_application/config/session/application/config.py
@@ -7,6 +7,7 @@
from sampletones_application.config.session.application.history import HistoryConfig
from sampletones_application.config.session.application.playback import PlaybackConfig
from sampletones_application.config.session.application.shortcuts import ShortcutsConfig
+from sampletones_application.config.session.application.tracker import TrackerConfig
from sampletones_core.data import Metadata
@@ -39,9 +40,13 @@ class ApplicationConfig(BaseModel):
)
playback: PlaybackConfig = Field(
default_factory=PlaybackConfig,
- description="Playback behaviour preferences.",
+ description="Playback behavior preferences.",
)
shortcuts: ShortcutsConfig = Field(
default_factory=ShortcutsConfig,
description="The keybinding scheme and the actions rebound on it.",
)
+ tracker: TrackerConfig = Field(
+ default_factory=TrackerConfig,
+ description="How the pattern grid is typed into.",
+ )
diff --git a/src/sampletones_application/config/session/application/tracker.py b/src/sampletones_application/config/session/application/tracker.py
new file mode 100644
index 000000000..b2a325131
--- /dev/null
+++ b/src/sampletones_application/config/session/application/tracker.py
@@ -0,0 +1,12 @@
+from pydantic import BaseModel, Field
+
+from sampletones_application.constants.tracker import DEFAULT_OCTAVE, MAX_OCTAVE, MIN_OCTAVE
+
+
+class TrackerConfig(BaseModel):
+ octave: int = Field(
+ default=DEFAULT_OCTAVE,
+ ge=MIN_OCTAVE,
+ le=MAX_OCTAVE,
+ description="The octave a note key types into the pattern grid.",
+ )
diff --git a/src/sampletones_application/constants/conversion.py b/src/sampletones_application/constants/conversion.py
new file mode 100644
index 000000000..4f33df8d0
--- /dev/null
+++ b/src/sampletones_application/constants/conversion.py
@@ -0,0 +1,4 @@
+from typing import Final
+
+MAX_STEM_SOURCES: Final[int] = 8
+MIN_CHANNEL_CAP: Final[int] = 1
diff --git a/src/sampletones_application/constants/instruments.py b/src/sampletones_application/constants/instruments.py
new file mode 100644
index 000000000..d7345e6fa
--- /dev/null
+++ b/src/sampletones_application/constants/instruments.py
@@ -0,0 +1,7 @@
+from typing import Final
+
+from sampletones_core.constants.enums import ChannelName, GeneratorName
+
+INSTRUMENT_CHANNEL: Final[ChannelName] = ChannelName.PULSE1
+AUDITION_GENERATOR: Final[GeneratorName] = GeneratorName.PULSE
+AUDITION_TICKS: Final[int] = 30
diff --git a/src/sampletones_application/constants/playback.py b/src/sampletones_application/constants/playback.py
index 31324fedc..307d89fda 100644
--- a/src/sampletones_application/constants/playback.py
+++ b/src/sampletones_application/constants/playback.py
@@ -1,11 +1,6 @@
from enum import StrEnum
-from math import ceil
from typing import Final
-from sampletones_core.timing import RowRate
-from sampletones_shared.constants.nes import MAX_NES_FREQUENCY
-from sampletones_shared.constants.project import MAX_SPEED, MIN_TEMPO
-
class FollowMode(StrEnum):
"""How far the sequencer view chases the playhead during song playback.
@@ -30,12 +25,3 @@ def follows_row(self) -> bool:
DEFAULT_FOLLOW_MODE: Final[FollowMode] = FollowMode.ROWS
-
-MIN_TICKS_PER_ROW: Final[int] = 1
-MAX_TICKS_PER_ROW: Final[int] = ceil(
- RowRate.from_parameters(
- tempo=MIN_TEMPO,
- speed=MAX_SPEED,
- nes_frequency=MAX_NES_FREQUENCY,
- ).ticks_per_row
-)
diff --git a/src/sampletones_application/constants/sequencer.py b/src/sampletones_application/constants/sequencer.py
index 0415da07c..160a5d1d3 100644
--- a/src/sampletones_application/constants/sequencer.py
+++ b/src/sampletones_application/constants/sequencer.py
@@ -1,5 +1,5 @@
from typing import Final, Optional, Tuple
-from sampletones_core.constants.enums import GeneratorName
+from sampletones_core.constants.enums import ChannelName
-CHANNEL_AXIS: Final[Tuple[Optional[GeneratorName], ...]] = (None,) + tuple(GeneratorName.items())
+CHANNEL_AXIS: Final[Tuple[Optional[ChannelName], ...]] = (None,) + tuple(ChannelName.items())
diff --git a/src/sampletones_application/constants/tracker.py b/src/sampletones_application/constants/tracker.py
new file mode 100644
index 000000000..229abd1b9
--- /dev/null
+++ b/src/sampletones_application/constants/tracker.py
@@ -0,0 +1,5 @@
+from typing import Final
+
+MIN_OCTAVE: Final[int] = 0
+MAX_OCTAVE: Final[int] = 7
+DEFAULT_OCTAVE: Final[int] = 2
diff --git a/src/sampletones_application/coordinators/export/__init__.py b/src/sampletones_application/coordinators/export/__init__.py
new file mode 100644
index 000000000..a9f863fdd
--- /dev/null
+++ b/src/sampletones_application/coordinators/export/__init__.py
@@ -0,0 +1,9 @@
+from sampletones_application.coordinators.export.instrument import (
+ InstrumentExportCoordinator,
+)
+from sampletones_application.coordinators.export.song import SongExportCoordinator
+
+__all__ = [
+ "InstrumentExportCoordinator",
+ "SongExportCoordinator",
+]
diff --git a/src/sampletones_application/coordinators/export/instrument.py b/src/sampletones_application/coordinators/export/instrument.py
new file mode 100644
index 000000000..4e662c2d4
--- /dev/null
+++ b/src/sampletones_application/coordinators/export/instrument.py
@@ -0,0 +1,126 @@
+from pathlib import Path
+from typing import Dict, Optional, Tuple
+
+from sampletones_application.categories.elements.global_ import FileFilterElements
+from sampletones_application.categories.exports import (
+ EXPORT_INSTRUMENT_FILTERS,
+ INSTRUMENT_EXPORT_FORMATS,
+)
+from sampletones_application.categories.hierarchy import Page, Panel, TextType
+from sampletones_application.categories.manager import LanguageManager
+from sampletones_application.logic.export.instrument.logic import InstrumentExportLogic
+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_core.constants.enums import ChannelName
+from sampletones_core.exports.format import ExportFormat
+from sampletones_core.exports.request import InstrumentSource
+from sampletones_core.exports.scope import ExportScope
+
+
+class InstrumentExportCoordinator:
+ """The screen one instrument's export is asked for on, wherever the request came from.
+
+ Every surface offering an instrument export — the Reconstructions tab's channel buttons, the
+ sequencer's voice menu — raises the same dialog here, so an instrument is written the same way
+ whichever of them asked. All three formats that write a single instrument are offered at once,
+ which leaves choosing one to the dialog's own type selector rather than to the menu that
+ reached it.
+ """
+
+ def __init__(
+ self,
+ export_logic: InstrumentExportLogic,
+ language_manager: LanguageManager,
+ ) -> None:
+ self._logic = export_logic
+ self._title = language_manager["reconstructions.instruments.title.export_instrument_dialog"]
+ self._filter_names: Dict[ExportFormat, str] = {
+ export_format: self._filter_name(language_manager, element)
+ for export_format, element in EXPORT_INSTRUMENT_FILTERS.items()
+ }
+
+ def voice_instruments(self, voice_id: str) -> Tuple[Optional[ChannelName], ...]:
+ """What one voice offers to an export, each named by the channel it is stated for.
+
+ A menu offering an export asks the exporter itself what a voice holds, so what the menu
+ prints and what a click writes are the same answer.
+
+ Args:
+ voice_id: The voice whose instruments are offered.
+
+ Returns:
+ Tuple[Optional[ChannelName], ...]: One entry per instrument the voice holds, ``None``
+ where the voice holds the one set of envelopes every channel reads.
+ """
+ return self._logic.voice_instruments(voice_id)
+
+ def request_voice(
+ self,
+ voice_id: str,
+ channel_name: Optional[ChannelName],
+ ) -> None:
+ """Asks where one of a project voice's instruments goes, then writes it there.
+
+ Both surfaces naming a voice — the sequencer's voice menu and the Reconstructions tab's
+ export button — reach a file this way, so the same voice is written the same bytes
+ whichever of them asked.
+
+ Args:
+ voice_id: The voice the instrument belongs to.
+ channel_name: The channel the instrument is stated for, ``None`` where the voice
+ holds the one set of envelopes every channel reads.
+ """
+ exportable = self._logic.voice_instrument(voice_id, channel_name)
+ if exportable is not None:
+ self.request(exportable.source, exportable.name)
+
+ def request(self, source: InstrumentSource, suggested_name: str) -> None:
+ """Asks where one instrument goes, then writes it there.
+
+ The dialog opens on the folder the last instrument landed in and suggests the instrument's
+ own name, leaving the format to the type selector and to any extension typed over it.
+
+ Args:
+ source: The instrument to write, awaiting the name its destination gives it.
+ suggested_name: The name the dialog opens with, which the instrument keeps unless the
+ reader renames the file.
+ """
+ destination = save_file_dialog(
+ title=self._title,
+ initial_directory=self._logic.suggested_directory,
+ default_filename=suggested_name,
+ filters=self._filters(),
+ )
+
+ self._write(destination, source)
+
+ @ignore_none_path
+ def _write(self, destination: Path, source: InstrumentSource) -> None:
+ self._logic.export(destination, source)
+
+ def _filters(self) -> Tuple[FileFilter, ...]:
+ """The types a destination may be given, one per format writing a single instrument.
+
+ Naming each format's own type puts the programs an export can reach into the dialog's type
+ selector, so the one picked there names the format the instrument is written in.
+ """
+ return tuple(self._filter(export_format) for export_format in INSTRUMENT_EXPORT_FORMATS)
+
+ def _filter(self, export_format: ExportFormat) -> FileFilter:
+ return FileFilter.for_extensions(
+ self._filter_names[export_format],
+ [self._logic.backends[export_format].extension(ExportScope.INSTRUMENT)],
+ )
+
+ @staticmethod
+ def _filter_name(
+ language_manager: LanguageManager,
+ element: FileFilterElements,
+ ) -> str:
+ return language_manager[
+ Page.GLOBAL,
+ Panel.DIALOG,
+ TextType.FILTER,
+ element,
+ ]
diff --git a/src/sampletones_application/coordinators/export/song.py b/src/sampletones_application/coordinators/export/song.py
new file mode 100644
index 000000000..87faca81d
--- /dev/null
+++ b/src/sampletones_application/coordinators/export/song.py
@@ -0,0 +1,68 @@
+from typing import Optional
+
+from sampletones_application.logic.export import SongExportLogic
+from sampletones_application.ui.panels.dialogs.export import GUIExportWindow
+from sampletones_application.view_model.shared.export import SongExportViewModel
+
+
+class SongExportCoordinator:
+ """Owns the screen a running export holds: the window opens on the run's first word and goes
+ when its outcome arrives.
+
+ An export is started from wherever the reader asked for one — a menu, a panel, the system's
+ own save dialog — so nothing is orchestrated here on the way in. What is orchestrated is the
+ way out: the window leaves the screen the moment the run is over, which is what lets the
+ dialog reporting the outcome open onto a clear screen.
+ """
+
+ def __init__(
+ self,
+ export_logic: SongExportLogic,
+ *,
+ window: GUIExportWindow,
+ ) -> None:
+ self._logic = export_logic
+ self._window = window
+ self._view_model: Optional[SongExportViewModel] = None
+ self._window_open = False
+
+ self._logic.on_view_changed = self._on_view_changed
+ self._logic.on_started = self._open
+ self._logic.on_finished = self._close
+
+ self._window.on_cancel = self._logic.cancel
+
+ @property
+ def is_active(self) -> bool:
+ """An export holds the screen from its first word until the outcome that ends it."""
+ return self._logic.is_active
+
+ def cleanup(self) -> None:
+ """Winds a running export down for application exit."""
+ self._logic.cleanup()
+
+ def _on_view_changed(self, view_model: SongExportViewModel) -> None:
+ """Keeps the open window standing at where the run has got to."""
+ self._view_model = view_model
+ if self._window_open:
+ self._window.update_view(view_model)
+
+ def _open(self) -> None:
+ self._window_open = True
+ self._window.open(self._require_view_model())
+
+ def _close(self) -> None:
+ self._window_open = False
+ self._view_model = None
+ self._window.hide()
+
+ def _require_view_model(self) -> SongExportViewModel:
+ """The run the window opens on.
+
+ Raises:
+ SystemError: when the window is raised before the logic offers a view.
+ """
+ if self._view_model is None:
+ raise SystemError("The export window is opened over the view the logic emits")
+
+ return self._view_model
diff --git a/src/sampletones_application/coordinators/original_audio.py b/src/sampletones_application/coordinators/original_audio.py
index 03c98c731..4406e0ca3 100644
--- a/src/sampletones_application/coordinators/original_audio.py
+++ b/src/sampletones_application/coordinators/original_audio.py
@@ -5,7 +5,8 @@
from sampletones_application.utils.gui.dialogs import DialogsRenderer
from sampletones_shared.exceptions import SampleToNESError
from sampletones_shared.logger import logger
-from sampletones_shared.utils.system.paths import open_path_in_explorer
+from sampletones_shared.utils.system.paths import first_missing, to_paths
+from sampletones_shared.utils.system.reveal.selection import open_paths_in_explorer
class OriginalAudioLocator:
@@ -34,14 +35,16 @@ def locate(self, filepath: Path) -> None:
self._dialogs.show_error(exception, self._language_manager["reconstructions.browser.message.load_error"])
return
- if audio_filepath is None:
+ audio_paths = to_paths(audio_filepath)
+ if not audio_paths:
return
- if not audio_filepath.exists():
+ missing_path = first_missing(audio_paths)
+ if missing_path is not None:
self._dialogs.show_file_not_found(
- audio_filepath,
+ missing_path,
self._language_manager["reconstructions.reconstruction.message.locate_audio_failed"],
)
return
- open_path_in_explorer(audio_filepath)
+ open_paths_in_explorer(audio_paths)
diff --git a/src/sampletones_application/coordinators/project.py b/src/sampletones_application/coordinators/project.py
index 63f755e37..4eafc8432 100644
--- a/src/sampletones_application/coordinators/project.py
+++ b/src/sampletones_application/coordinators/project.py
@@ -1,3 +1,4 @@
+from functools import partial
from pathlib import Path
from typing import Dict, Optional, Tuple
@@ -7,9 +8,9 @@
GlobalDialogTitleElements,
GlobalMessageElements,
)
+from sampletones_application.categories.exports import EXPORT_PROJECT_ELEMENTS
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
@@ -31,9 +32,10 @@
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.trackers.backend import TrackerBackend
-from sampletones_core.trackers.format import TrackerFormat
-from sampletones_core.trackers.scope import ExportScope
+from sampletones_application.utils.gui.frame import FrameCallbackManager
+from sampletones_core.exports.backend import ExportBackend
+from sampletones_core.exports.format import ExportFormat
+from sampletones_core.exports.scope import ExportScope
from sampletones_shared.constants.project import (
DEFAULT_EXPORT_NAME,
DEFAULT_PROJECT_FILENAME,
@@ -67,7 +69,7 @@ def __init__(
session_manager: SessionManager,
export_service: ExportService,
*,
- tracker_backends: Dict[TrackerFormat, TrackerBackend],
+ export_backends: Dict[ExportFormat, ExportBackend],
dialogs: DialogsRenderer,
language_manager: LanguageManager,
on_tab_switch: Callback,
@@ -77,7 +79,7 @@ def __init__(
self._project_manager = project_manager
self._session_manager = session_manager
self._export_service = export_service
- self._tracker_backends = tracker_backends
+ self._export_backends = export_backends
self._dialogs = dialogs
self._language_manager = language_manager
self._on_tab_switch = on_tab_switch
@@ -195,17 +197,17 @@ def _get_project_filename(self, extension: str) -> str:
name = self.project_name or DEFAULT_EXPORT_NAME
return get_filename(name, extension)
- def export_project_dialog(self, tracker_format: TrackerFormat) -> None:
- """Prompts for a destination and writes the open project in ``tracker_format``.
+ def export_project_dialog(self, export_format: ExportFormat) -> None:
+ """Prompts for a destination and writes the open project in ``export_format``.
Args:
- tracker_format: The tracker the project is written for.
+ export_format: The format the project is written in.
"""
if not self._project_controller.is_open:
return
- backend = self._tracker_backends[tracker_format]
- elements = TRACKER_PROJECT_ELEMENTS[tracker_format]
+ backend = self._export_backends[export_format]
+ elements = EXPORT_PROJECT_ELEMENTS[export_format]
extension = backend.extension(ExportScope.PROJECT)
path = self._session_manager.get_project_path()
filepath = save_file_dialog(
@@ -220,7 +222,7 @@ def export_project_dialog(self, tracker_format: TrackerFormat) -> None:
),
)
- self._handle_export_project(filepath, tracker_format)
+ self._handle_export_project(filepath, export_format)
def _open_dialog(self) -> None:
filepath = open_file_dialog(
@@ -242,10 +244,10 @@ def _handle_save_as(self, filepath: Path) -> bool:
return self._save(filepath)
@ignore_none_path
- def _handle_export_project(self, filepath: Path, tracker_format: TrackerFormat) -> None:
+ def _handle_export_project(self, filepath: Path, export_format: ExportFormat) -> None:
self._export_service.export_project(
filepath,
- self._tracker_backends[tracker_format],
+ self._export_backends[export_format],
self._project_controller.export_request,
)
@@ -295,27 +297,41 @@ def _save(self, filepath: Path) -> bool:
return True
def _on_export_result(self, result: ExportResult) -> None:
- """Reports a finished project export in the words of the format it was written in."""
+ """Reports a finished project export in the words of the format it was written in.
+
+ A run long enough to watch held a window while it ran, and DearPyGui carries one modal at
+ a time, so the report waits for the frame that draws the screen without it.
+ """
match result:
case ExportSuccess(
kind=ExportKind.PROJECT,
- tracker_format=TrackerFormat() as tracker_format,
+ export_format=ExportFormat() as export_format,
):
- self._dialogs.show_info(
- TAG_GLOBAL_DIALOG_MODULE_EXPORTED,
- self._message(TRACKER_PROJECT_ELEMENTS[tracker_format].exported_message),
- self._title(GlobalDialogTitleElements.PROJECT_EXPORTED),
+ self._present(
+ partial(
+ self._dialogs.show_info,
+ TAG_GLOBAL_DIALOG_MODULE_EXPORTED,
+ self._message(EXPORT_PROJECT_ELEMENTS[export_format].exported_message),
+ self._title(GlobalDialogTitleElements.PROJECT_EXPORTED),
+ )
)
case ExportError(
kind=ExportKind.PROJECT,
- tracker_format=TrackerFormat() as tracker_format,
+ export_format=ExportFormat() as export_format,
exception=exception,
):
- self._dialogs.show_error(
- exception,
- self._message(TRACKER_PROJECT_ELEMENTS[tracker_format].export_failed_message),
+ self._present(
+ partial(
+ self._dialogs.show_error,
+ exception,
+ self._message(EXPORT_PROJECT_ELEMENTS[export_format].export_failed_message),
+ )
)
+ def _present(self, raise_dialog: VoidCallback) -> None:
+ """Raises ``raise_dialog`` once the frame the export window left the screen in has finished."""
+ FrameCallbackManager.set_frame_callback(raise_dialog)
+
def _guard_open(
self,
*,
diff --git a/src/sampletones_application/coordinators/reconstruction.py b/src/sampletones_application/coordinators/reconstruction.py
index d2e5222ad..e3e003fe7 100644
--- a/src/sampletones_application/coordinators/reconstruction.py
+++ b/src/sampletones_application/coordinators/reconstruction.py
@@ -7,12 +7,16 @@
from sampletones_application.coordinators.tabs.reconstruction import (
ReconstructionTabCoordinator,
)
+from sampletones_application.logic.reconstruction.edit import (
+ ChannelEdit,
+ ReconstructionEdit,
+)
from sampletones_application.logic.reconstruction.manager import ReconstructionManager
from sampletones_application.services import (
RegeneratedInstrument,
RegenerationResult,
RegenerationService,
- ServiceCancelled,
+ ServiceCanceled,
ServiceError,
ServiceSuccess,
)
@@ -28,14 +32,13 @@
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
-from sampletones_core.constants.enums import FeatureKey, GeneratorName
+from sampletones_core.constants.enums import ChannelName, FeatureKey
from sampletones_core.exporters import Features
-from sampletones_core.types.feature import FeatureValue
from sampletones_shared.exceptions import SampleToNESError
from sampletones_shared.logger import logger
from sampletones_shared.paths.extensions import EXT_FILE_RECONSTRUCTION
from sampletones_shared.types.callback import Callback, VoidCallback
-from sampletones_shared.utils.system.paths import get_filename
+from sampletones_shared.utils.system.paths import first_missing, get_filename
class ReconstructionCoordinator:
@@ -64,7 +67,7 @@ def __init__(
language_manager: LanguageManager,
on_tab_switch: Callback,
on_session_state_changed: VoidCallback,
- on_reconstruction_updated: Callable[[RegeneratedInstrument], None],
+ on_reconstruction_updated: Callable[[ReconstructionEdit], None],
is_reconstruction_embedded: Callable[[], bool],
) -> None:
self._reconstruction_manager = reconstruction_manager
@@ -266,10 +269,9 @@ def _close(self) -> None:
def regenerate_instrument(
self,
- generator_name: GeneratorName,
- features: Features,
+ channel_name: ChannelName,
feature_key: FeatureKey,
- data: FeatureValue,
+ features: Features,
) -> None:
reconstruction_data = self._reconstruction_manager.current_reconstruction
if reconstruction_data is None:
@@ -277,10 +279,9 @@ def regenerate_instrument(
accepted = self._regeneration_service.start(
reconstruction_data.reconstruction,
- generator_name,
- features,
+ channel_name,
feature_key,
- data,
+ features,
)
if accepted:
self._set_reconstruction_dimmed(True)
@@ -291,10 +292,10 @@ def on_reconstruction_loaded(self) -> None:
raise RuntimeError("No reconstruction is loaded after loading process")
self._audio_device_manager.stop()
- audio_filepath = reconstruction_data.reconstruction.audio_filepath
- if audio_filepath is not None and not audio_filepath.exists():
+ missing_path = first_missing(reconstruction_data.reconstruction.audio_filepath)
+ if missing_path is not None:
self._dialogs.show_file_not_found(
- audio_filepath,
+ missing_path,
self._language_manager["reconstructions.browser.message.audio_file_not_found"],
)
@@ -307,32 +308,42 @@ def _on_closed(self) -> None:
self._tab.close_reconstruction()
self._session_manager.set_current_reconstruction(None)
- def _on_updated(self, outcome: RegeneratedInstrument) -> None:
- """Applies a regenerated reconstruction across the open document and project.
+ def apply_edit(self, edit: ReconstructionEdit) -> None:
+ """Applies an edited reconstruction across the open document and project.
- The owning-sample hook runs first, while the manager still holds the prior
- reconstruction, so it can locate the owned sample by identity and record the
- edit against the project history, tagged with the channel and feature the
- ``outcome`` names. The open document then rebinds to the new reconstruction,
- keeping the editor and any owned sample sharing one object.
+ Every edit of the open document arrives here, so one path answers a regenerated
+ instrument and a removed recording alike. The owning-sample hook runs first, while
+ the manager still holds the prior reconstruction, so it can locate the owned sample
+ by identity and record the edit against the project history as the ``edit``
+ describes itself. The open document then rebinds to the new reconstruction, keeping
+ the editor and any owned sample sharing one object.
"""
- self._on_reconstruction_updated_callback(outcome)
- self._reconstruction_manager.apply_regenerated(outcome.reconstruction)
+ self._on_reconstruction_updated_callback(edit)
+ self._reconstruction_manager.apply_edited(edit.reconstruction)
self._tab.update_reconstruction()
self._reconstruction_manager.mark_updated()
def _on_regeneration_result(self, result: RegenerationResult) -> None:
match result:
case ServiceSuccess(value=outcome):
- self._on_updated(outcome)
+ self.apply_edit(self._channel_edit(outcome))
case ServiceError(exception=exception):
logger.error_with_traceback(exception, "Regeneration failed")
self._dialogs.show_error(exception)
- case ServiceCancelled():
- logger.info("Regeneration cancelled")
+ case ServiceCanceled():
+ logger.info("Regeneration canceled")
self._set_reconstruction_dimmed(self._regeneration_service.is_running())
+ @staticmethod
+ def _channel_edit(outcome: RegeneratedInstrument) -> ChannelEdit:
+ """Reads a regeneration result as the edit the project history records."""
+ return ChannelEdit(
+ reconstruction=outcome.reconstruction,
+ channel_name=outcome.channel_name,
+ feature_key=outcome.feature_key,
+ )
+
def _set_reconstruction_dimmed(self, dimmed: bool) -> None:
"""Fades the reconstruction waveform while the regeneration worker is busy.
diff --git a/src/sampletones_application/coordinators/render.py b/src/sampletones_application/coordinators/render.py
index b2ed163a5..5b024108f 100644
--- a/src/sampletones_application/coordinators/render.py
+++ b/src/sampletones_application/coordinators/render.py
@@ -57,7 +57,7 @@ def __init__(
self._logic.on_choose_destination = self._choose_destination
self._logic.on_success = self._on_success
self._logic.on_error = self._on_error
- self._logic.on_cancelled = self._on_cancelled
+ self._logic.on_canceled = self._on_canceled
self._window.on_settings_changed = self._logic.apply
self._window.on_browse = self._logic.request_destination
@@ -138,7 +138,7 @@ def _on_error(self, exception: Exception) -> None:
self._close()
self._present(partial(self._dialogs.show_error, exception, self._msg_failed))
- def _on_cancelled(self) -> None:
+ def _on_canceled(self) -> None:
"""Closes the dialog of a render that was stopped, which leaves no file to report."""
self._close()
diff --git a/src/sampletones_application/coordinators/tabs/instructions.py b/src/sampletones_application/coordinators/tabs/instructions.py
index fb6d0b4fa..d544ff6b4 100644
--- a/src/sampletones_application/coordinators/tabs/instructions.py
+++ b/src/sampletones_application/coordinators/tabs/instructions.py
@@ -67,7 +67,7 @@
)
from sampletones_application.view_model.shared.audio_data import AudioData
from sampletones_core.audio import AudioDeviceManager
-from sampletones_core.constants.enums import LibraryGeneratorName
+from sampletones_core.constants.enums import GeneratorName
from sampletones_core.library import InstructionLibraryKey
from sampletones_core.structures.tree import FileSystemNode
from sampletones_shared.exceptions import LibraryDisplayError, SampleToNESError
@@ -80,7 +80,7 @@
class _StackedGraphPanel(Protocol):
- """A centre-column card whose graph display follows a viewport-driven height."""
+ """A center-column card whose graph display follows a viewport-driven height."""
def set_display_height(self, height: int) -> None: ...
@@ -156,7 +156,7 @@ def __init__(
self._library_logic.on_view_changed = self._library_panel.update_view
self._library_logic.on_generation_completed = self._on_generation_completed
self._library_logic.on_generation_error = self._on_generation_error
- self._library_logic.on_generation_cancelled = self._on_generation_cancelled
+ self._library_logic.on_generation_canceled = self._on_generation_canceled
self._library_logic.on_load_file_not_found = self._on_library_file_not_found
self._library_logic.on_load_error = self._on_library_load_error
@@ -178,12 +178,14 @@ def __init__(
self._waveform_panel = GUIInstructionWaveformPanel(
initial_collapsed=session_manager.is_card_collapsed(TAG_INSTRUCTIONS_INSTRUCTION_PANEL_WAVEFORM),
layout=layout.graphs,
+ channel_colors=layout.channel_colors,
language_manager=language_manager,
status_bar=status_bar,
)
self._spectrum_panel = GUIInstructionSpectrumPanel(
initial_collapsed=session_manager.is_card_collapsed(TAG_INSTRUCTIONS_INSTRUCTION_PANEL_SPECTRUM),
layout=layout.graphs,
+ channel_colors=layout.channel_colors,
language_manager=language_manager,
status_bar=status_bar,
)
@@ -241,7 +243,7 @@ def _request_generate_library(self) -> None:
def _on_generator_selected(
self,
library_key: InstructionLibraryKey,
- generator_name: LibraryGeneratorName,
+ generator_name: GeneratorName,
) -> None:
self._library_logic.load_library_and_set_current(library_key)
self._library_logic.load_generator(generator_name)
@@ -288,10 +290,10 @@ def _on_generation_error(self, exception: Exception) -> None:
self._language_manager["instructions.library.message.status_generation_failed"],
)
- def _on_generation_cancelled(self) -> None:
+ def _on_generation_canceled(self) -> None:
self._dialogs.show_info(
TAG_INSTRUCTIONS_LIBRARY_PANEL,
- self._language_manager["instructions.library.message.status_generation_cancelled"],
+ self._language_manager["instructions.library.message.status_generation_canceled"],
self._ttl_generation_status,
modal=True,
)
@@ -351,11 +353,11 @@ def _close_instruction(self) -> None:
self._instruction_player_logic.clear_audio()
def _on_card_collapse_changed(self, card_tag: str, collapsed: bool) -> None:
- """Persists a centre-column card's collapsed state so it restores on the next launch."""
+ """Persists a center-column card's collapsed state so it restores on the next launch."""
self._session_manager.set_card_collapsed(card_tag, collapsed)
def _repaint_library_favorites(self, node: FileSystemNode) -> None:
- """Repaints the row whose star was toggled: the catalogue lists a library once, so it is one row."""
+ """Repaints the row whose star was toggled: the catalog lists a library once, so it is one row."""
self._library_panel.update_favorite_indicators((node,))
def _on_library_collapse_changed(self, card_tag: str, collapsed: bool) -> None:
@@ -450,7 +452,7 @@ def create_tab(self) -> None:
self._sync_graph_heights()
def _build_display_column(self, parent: str) -> None:
- """Stacks the waveform and spectrum cards down the centre column."""
+ """Stacks the waveform and spectrum cards down the center column."""
self._waveform_panel.create_panel(parent)
dpg.add_spacer(height=self._geometry.panel_gap, parent=parent)
self._spectrum_panel.create_panel(parent)
@@ -487,7 +489,7 @@ def load_library_safely(self, filepath: Path) -> None:
logger.warning(f"Could not load library from {logger.format_path(filepath)}: {exception}")
def save_browser_shape(self) -> None:
- """Writes down the rows the catalogue stands open, so a later run brings them back."""
+ """Writes down the rows the catalog stands open, so a later run brings them back."""
self._session_manager.set_expanded_rows(
self._library_panel.tag,
self._library_panel.expanded_rows,
diff --git a/src/sampletones_application/coordinators/tabs/main.py b/src/sampletones_application/coordinators/tabs/main.py
index 3cc17adab..66125638a 100644
--- a/src/sampletones_application/coordinators/tabs/main.py
+++ b/src/sampletones_application/coordinators/tabs/main.py
@@ -16,7 +16,7 @@
from sampletones_application.logic.main.explorer import ExplorerLogic
from sampletones_application.logic.shared.tree import TreeLogic
from sampletones_application.parameters.main import MainTabParameters
-from sampletones_application.services.conversion import ConversionService
+from sampletones_application.services.conversion.service import ConversionService
from sampletones_application.tags.compose import compose_tag
from sampletones_application.tags.general import (
SUF_PANEL_CENTER,
@@ -32,7 +32,9 @@
TAG_MAIN_CONFIG_PANEL_CONFIG_CELL,
TAG_MAIN_CONFIG_TABLE_CONFIG_ROW,
TAG_MAIN_CONVERTER_DIALOG_CANCEL,
+ TAG_MAIN_CONVERTER_DIALOG_DISCARD_STEMS,
TAG_MAIN_CONVERTER_DIALOG_LOAD,
+ TAG_MAIN_CONVERTER_DIALOG_OVERWRITE_TARGET,
TAG_MAIN_CONVERTER_PANEL,
TAG_MAIN_EXPLORER_DIALOG_CONVERTER_RUNNING,
TAG_MAIN_EXPLORER_PANEL,
@@ -42,6 +44,7 @@
from sampletones_application.ui.elements.layout.columns import ColumnSpec, TabColumns
from sampletones_application.ui.elements.layout.responsive import expanded_side_width
from sampletones_application.ui.elements.status import GUIStatusBar
+from sampletones_application.ui.panels.dialogs.stem_selection import GUIStemSelectionWindow
from sampletones_application.ui.panels.main.advanced import GUIAdvancedSettingsPanel
from sampletones_application.ui.panels.main.config import GUIConfigPanel
from sampletones_application.ui.panels.main.converter import GUIConverterPanel
@@ -61,7 +64,8 @@
ReconstructorPanelViewModel,
)
from sampletones_core.audio import AudioDeviceManager
-from sampletones_core.constants.enums import GeneratorName
+from sampletones_core.constants.enums import ChannelName
+from sampletones_core.reconstructions.converter import top_level_audio_files
from sampletones_core.structures.tree import FileSystemNode
from sampletones_shared.logger import logger
from sampletones_shared.types.callback import PathCallback, VoidCallback
@@ -103,9 +107,10 @@ def __init__(
status_bar: GUIStatusBar,
on_load_file: PathCallback,
on_load_directory: VoidCallback,
- on_cancelled: VoidCallback,
+ on_canceled: VoidCallback,
on_refresh_trees: VoidCallback,
on_generate_library: VoidCallback,
+ stem_selection_window: GUIStemSelectionWindow,
) -> None:
self._language_manager = language_manager
self._config_manager = config_manager
@@ -118,13 +123,14 @@ def __init__(
self._on_busy_state_changed = on_busy_state_changed
self._on_refresh_trees = on_refresh_trees
self._dialogs = dialogs
+ self._stem_selection_window = stem_selection_window
self._geometry = layout.geometry
self._side_panel_count: int
self._config_height = layout.config_height
_msg_converter_error = language_manager["main.converter.message.status_error"]
_msg_no_files = language_manager["main.converter.message.status_no_files"]
- _msg_no_generators = language_manager["main.converter.message.status_no_generators"]
+ _msg_no_generators = language_manager["main.converter.message.status_no_channels"]
self._ttl_progress = language_manager["main.converter.title.progress_dialog"]
self._explorer_logic: ExplorerLogic = ExplorerLogic(
@@ -167,7 +173,7 @@ def __init__(
)
self._reconstructor_panel: GUIReconstructorPanel = GUIReconstructorPanel(
ReconstructorPanelViewModel(
- generators=frozenset(_config.generation.generators),
+ channels=frozenset(_config.generation.channels),
drive=_config.generation.drive,
),
layout=layout.main.reconstructor,
@@ -200,6 +206,8 @@ def __init__(
)
self._converter_panel: GUIConverterPanel = GUIConverterPanel(
layout=layout.main.converter,
+ stems_layout=layout.stems,
+ inputs=layout.inputs,
path_colors=layout.path_colors,
initial_collapsed=session_manager.is_card_collapsed(TAG_MAIN_CONVERTER_PANEL),
language_manager=language_manager,
@@ -222,6 +230,9 @@ def __init__(
self._explorer_panel.set_callbacks(
on_wave_file_clicked=self._on_wave_file_clicked,
on_directory_clicked=self._on_directory_clicked,
+ on_directory_add_requested=self._on_directory_add_requested,
+ on_file_add_requested=self._on_file_add_requested,
+ can_add_stems=self._can_add_stems,
on_reconstruct_file=self._request_reconstruct_file,
on_reconstruct_directory=self._request_reconstruct_directory,
on_load_reconstruction=on_load_reconstruction,
@@ -243,16 +254,29 @@ def __init__(
_msg_no_generators,
self._ttl_progress,
)
+ self._converter_logic.on_target_exists = self._confirm_overwriting_target
self._converter_logic.is_library_available = library_manager.is_library_available_for_config
self._converter_logic.cancel_library_generation = library_manager.cancel_generation
self._converter_logic.on_load_file = on_load_file
self._converter_logic.on_load_directory = on_load_directory
- self._converter_logic.on_cancelled = on_cancelled
+ self._converter_logic.on_canceled = on_canceled
self._converter_logic.generate_library = on_generate_library
+ config_manager.add_config_change_callback(self._converter_logic.refresh_view)
library_manager.on_generation_progress_extra = conversion_service.forward_library_progress
self._converter_panel.on_convert_requested = self._converter_logic.start_conversion
self._converter_panel.on_cancel_requested = self._request_cancel_confirmation
+ self._converter_panel.on_stems_mode_changed = self._request_stems_mode
+ self._converter_panel.on_channel_cap_changed = self._converter_logic.set_channel_cap
+ self._converter_panel.on_hierarchy_mode_changed = self._converter_logic.set_hierarchy_mode
+ self._converter_panel.on_source_channels_changed = self._converter_logic.set_source_channels
+ self._converter_panel.on_source_removed = self._converter_logic.remove_source
+ self._converter_panel.on_source_moved = self._converter_logic.move_source_within_level
+ self._converter_panel.on_source_level_joined = self._converter_logic.join_source_level
+ self._converter_panel.on_source_isolated = self._converter_logic.isolate_source
+ self._converter_panel.on_source_dropped_on_source = self._converter_logic.move_source_onto
+ self._converter_panel.on_source_dropped_on_level = self._converter_logic.move_source_to_new_level
+ self._stem_selection_window.on_add = self._converter_logic.add_sources
def _repaint_explorer_favorites(self, node: FileSystemNode) -> None:
"""Repaints the row whose star was toggled: the explorer mirrors the disk, so a path is one row."""
@@ -267,7 +291,7 @@ def _on_converter_view_changed(self, view_model: ConverterViewModel) -> None:
def _on_wave_file_clicked(self, filepath: Path) -> None:
if not self._is_operation_active():
- self._converter_logic.set_input_path(filepath, convert=False)
+ self._converter_logic.select_source(filepath)
def _on_directory_clicked(self, directory_path: Path) -> None:
if not self._is_operation_active():
@@ -277,13 +301,56 @@ def _request_reconstruct_file(self, filepath: Path) -> None:
if self._notify_converter_running():
return
- self._on_reconstruct_file(filepath)
+ self._leaving_stems_mode(lambda: self._on_reconstruct_file(filepath))
def _request_reconstruct_directory(self, directory_path: Path) -> None:
if self._notify_converter_running():
return
- self._on_reconstruct_directory(directory_path)
+ self._leaving_stems_mode(lambda: self._on_reconstruct_directory(directory_path))
+
+ def _leaving_stems_mode(self, reconstruct: VoidCallback) -> None:
+ """Runs a conversion the browser asked for, asking first where it would drop a stems list.
+
+ A Reconstruct names one file or one folder, which is what a classic conversion converts, so
+ the gathered recordings are what the reader is being asked about. Declining leaves the
+ setup as it stands and starts nothing.
+ """
+ if not self._converter_logic.stems_mode:
+ reconstruct()
+ return
+
+ self._confirm_discarding_stems(lambda: self._reconstruct_without_stems(reconstruct))
+
+ def _reconstruct_without_stems(self, reconstruct: VoidCallback) -> None:
+ self._converter_logic.set_stems_mode(False)
+ reconstruct()
+
+ def _confirm_discarding_stems(self, on_confirm: VoidCallback) -> None:
+ self._dialogs.show_confirmation(
+ TAG_MAIN_CONVERTER_DIALOG_DISCARD_STEMS,
+ self._language_manager["main.converter.message.discard_stems_prompt"],
+ self._language_manager["main.converter.title.discard_stems_dialog"],
+ on_confirm,
+ ok_label=self._language_manager["main.converter.label.discard_stems_button"],
+ cancel_label=self._language_manager["main.converter.label.keep_stems_button"],
+ on_cancel=self._converter_logic.refresh_view,
+ )
+
+ def _confirm_overwriting_target(self, target: Path) -> None:
+ """Asks before a conversion writes over the reconstruction already standing at its target.
+
+ A batch keeps what it finds and converts the rest, so this reaches the reader for a
+ single conversion — the one run whose output would replace a file already made.
+ """
+ self._dialogs.show_confirmation(
+ TAG_MAIN_CONVERTER_DIALOG_OVERWRITE_TARGET,
+ self._language_manager["main.converter.message.overwrite_target_prompt"],
+ self._language_manager["main.converter.title.overwrite_target_dialog"],
+ lambda: self._converter_logic.start_conversion(confirmed=True),
+ ok_label=self._language_manager["main.converter.label.overwrite_target_button"],
+ path=target,
+ )
def _notify_converter_running(self) -> bool:
if not self._is_operation_active():
@@ -300,10 +367,10 @@ def _notify_converter_running(self) -> bool:
def _on_conversion_success(self, success: ConversionSuccess) -> None:
self._on_refresh_trees()
- if success.is_file:
+ if success.is_single:
message = self._language_manager["main.converter.message.load_file_prompt"]
ok_label = self._language_manager["main.converter.label.load_button"]
- path = success.output_path
+ path: Optional[Path] = success.written[0]
else:
message = self._language_manager["main.converter.message.load_directory_prompt"]
ok_label = self._language_manager["main.converter.label.open_button"]
@@ -320,6 +387,51 @@ def _on_conversion_success(self, success: ConversionSuccess) -> None:
on_cancel=self._converter_logic.close,
)
+ def _request_stems_mode(self, stems_mode: bool) -> None:
+ """Answers the stems-mode switch, asking first where leaving it would drop recordings.
+
+ Turning stems mode off keeps the first recording, so a list of several loses the rest;
+ that is what the prompt confirms. Every other switch takes effect straight away.
+ """
+ if stems_mode or self._converter_logic.source_count <= 1:
+ self._converter_logic.set_stems_mode(stems_mode)
+ return
+
+ self._confirm_discarding_stems(lambda: self._converter_logic.set_stems_mode(False))
+
+ def _can_add_stems(self) -> bool:
+ """The converter is free to gather recordings into a stems conversion."""
+ return not self._is_operation_active()
+
+ def _on_file_add_requested(self, filepath: Path) -> None:
+ """Gathers one recording into a stems conversion, opening one where none is being built."""
+ if self._is_operation_active():
+ return
+
+ self._converter_logic.set_stems_mode(True)
+ self._converter_logic.add_sources([filepath])
+
+ def _on_directory_add_requested(self, directory_path: Path) -> None:
+ """Offers a folder's recordings to a stems conversion, asking which ones where they overflow.
+
+ Where the folder holds no more than the list has room for, every recording joins at once.
+ A fuller folder raises the selection window, which shows what fits already ticked.
+ """
+ if self._is_operation_active():
+ return
+
+ candidates = top_level_audio_files(directory_path)
+ if not candidates:
+ return
+
+ self._converter_logic.set_stems_mode(True)
+ room = self._converter_logic.room_for_sources
+ if len(candidates) <= room:
+ self._converter_logic.add_sources(candidates)
+ return
+
+ self._stem_selection_window.open(candidates, room)
+
def _request_cancel_confirmation(self) -> None:
self._dialogs.show_confirmation(
TAG_MAIN_CONVERTER_DIALOG_CANCEL,
@@ -345,7 +457,7 @@ def _update_reconstructor_panel_view(self) -> None:
config = self._config_manager.config
self._reconstructor_panel.update_view(
ReconstructorPanelViewModel(
- generators=frozenset(config.generation.generators),
+ channels=frozenset(config.generation.channels),
drive=config.generation.drive,
)
)
@@ -501,9 +613,9 @@ def save_browser_shape(self) -> None:
def refresh_browser(self) -> None:
self._explorer_panel.refresh()
- def toggle_generator(self, generator: GeneratorName) -> None:
- """Switches one generator in or out of the set a reconstruction is built from."""
- self._reconstructor_panel.toggle_generator(generator)
+ def toggle_channel(self, channel: ChannelName) -> None:
+ """Switches one channel in or out of the set a reconstruction is built from."""
+ self._reconstructor_panel.toggle_channel(channel)
def toggle_advanced_settings(self) -> None:
advanced_settings = self._session_manager.toggle_show_advanced_settings()
diff --git a/src/sampletones_application/coordinators/tabs/reconstruction.py b/src/sampletones_application/coordinators/tabs/reconstruction.py
index 53a77dbca..4e8891363 100644
--- a/src/sampletones_application/coordinators/tabs/reconstruction.py
+++ b/src/sampletones_application/coordinators/tabs/reconstruction.py
@@ -1,22 +1,33 @@
+from functools import partial
from pathlib import Path
-from typing import Callable, Dict, Optional, Sequence, Tuple
+from typing import Callable, Dict, Optional, Sequence
import dearpygui.dearpygui as dpg
from sampletones_application.categories.export import ExportMessages
+from sampletones_application.categories.exports import EXPORT_INSTRUMENT_FILTERS
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.export.instrument import (
+ InstrumentExportCoordinator,
+)
from sampletones_application.coordinators.original_audio import OriginalAudioLocator
from sampletones_application.coordinators.playback.guard import GuardedPlayer
from sampletones_application.coordinators.playback.protocol import AudioPlayerProtocol
+from sampletones_application.logic.history.manager import HistoryManager
+from sampletones_application.logic.project.controller import ProjectController
+from sampletones_application.logic.reconstruction.audition import (
+ InstrumentAuditionLogic,
+)
from sampletones_application.logic.reconstruction.browser.logic import BrowserLogic
from sampletones_application.logic.reconstruction.browser.manager import BrowserManager
+from sampletones_application.logic.reconstruction.edit import StemRemoval
+from sampletones_application.logic.reconstruction.editor import (
+ InstrumentEditDetail,
+ InstrumentEditor,
+)
from sampletones_application.logic.reconstruction.instruments import (
OnReconstructionInstrumentUpdatedCallback,
ReconstructionInstrumentsLogic,
@@ -50,8 +61,10 @@
TAG_RECONSTRUCTIONS_BROWSER_DIALOG_REMOVE_RECONSTRUCTION_CONFIRMATION,
TAG_RECONSTRUCTIONS_BROWSER_PANEL,
TAG_RECONSTRUCTIONS_INSTRUMENTS_PANEL,
+ TAG_RECONSTRUCTIONS_RECONSTRUCTION_DIALOG_REMOVE_STEM_CONFIRMATION,
TAG_RECONSTRUCTIONS_RECONSTRUCTION_PANEL_AUDIO,
TAG_RECONSTRUCTIONS_RECONSTRUCTION_PANEL_PLOT,
+ TAG_RECONSTRUCTIONS_RECONSTRUCTION_PANEL_STEMS,
)
from sampletones_application.ui.elements.layout.columns import ColumnSpec, TabColumns
from sampletones_application.ui.elements.layout.responsive import expanded_side_width
@@ -68,23 +81,28 @@
from sampletones_application.ui.panels.reconstruction.plot import (
GUIReconstructionPlotPanel,
)
+from sampletones_application.ui.panels.reconstruction.stems import (
+ GUIReconstructionStemsPanel,
+)
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
from sampletones_application.utils.gui.frame import FrameCallbackManager
+from sampletones_application.utils.gui.keyboard import ActivePredicate, KeyRouter
from sampletones_application.view_model.reconstruction.reconstruction import (
ReconstructionViewModel,
)
from sampletones_application.view_model.shared.audio_data import AudioData
from sampletones_core.audio import AudioDeviceManager
-from sampletones_core.constants.enums import GeneratorName
+from sampletones_core.constants.enums import ChannelName
from sampletones_core.exporters.truncation import EnvelopeTruncation
+from sampletones_core.exports.backend import ExportBackend
+from sampletones_core.exports.format import ExportFormat
+from sampletones_core.exports.scope import ExportScope
+from sampletones_core.reconstructions.reconstruction.stems.removal import without_stem
from sampletones_core.structures.tree import FileSystemNode
-from sampletones_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,
@@ -109,15 +127,22 @@ def __init__(
session_manager: SessionManager,
audio_device_manager: AudioDeviceManager,
reconstruction_manager: ReconstructionManager,
+ project_controller: ProjectController,
browser_manager: BrowserManager,
export_service: ExportService,
- tracker_backends: Dict[TrackerFormat, TrackerBackend],
+ export_backends: Dict[ExportFormat, ExportBackend],
on_load_reconstruction_with_confirmation: Callable[[Optional[Path]], None],
on_change_audio_state: VoidCallback,
on_favorite_changed: Callable[[FileSystemNode], None],
on_reconstruction_instrument_updated: OnReconstructionInstrumentUpdatedCallback,
+ on_reconstruction_stem_removed: Callable[[StemRemoval], None],
original_audio_locator: OriginalAudioLocator,
+ instrument_exports: InstrumentExportCoordinator,
+ history: HistoryManager,
+ instrument_edit_detail: InstrumentEditDetail,
*,
+ key_router: KeyRouter,
+ tab_active: ActivePredicate,
layout: ReconstructionTabParameters,
language_manager: LanguageManager,
dialogs: DialogsRenderer,
@@ -125,10 +150,18 @@ def __init__(
) -> None:
self._language_manager = language_manager
self._reconstruction_manager = reconstruction_manager
+ self._instrument_editor: InstrumentEditor = InstrumentEditor(
+ reconstruction_manager,
+ project_controller,
+ history,
+ instrument_edit_detail,
+ )
self._session_manager = session_manager
- self._tracker_backends = tracker_backends
+ self._export_backends = export_backends
+ self._instrument_exports = instrument_exports
self._dialogs = dialogs
self._original_audio_locator = original_audio_locator
+ self._on_reconstruction_stem_removed = on_reconstruction_stem_removed
self._geometry = layout.geometry
self._side_panel_count: int
@@ -138,14 +171,14 @@ def __init__(
self._msg_load_error = language_manager["reconstructions.browser.message.load_error"]
self._export_messages = ExportMessages.build(language_manager)
- self._instrument_filter_names: Dict[TrackerFormat, str] = {
- tracker_format: language_manager[
+ self._instrument_filter_names: Dict[ExportFormat, str] = {
+ export_format: language_manager[
Page.GLOBAL,
Panel.DIALOG,
TextType.FILTER,
element,
]
- for tracker_format, element in TRACKER_INSTRUMENT_FILTERS.items()
+ for export_format, element in EXPORT_INSTRUMENT_FILTERS.items()
}
self._browser_logic: BrowserLogic = BrowserLogic(
@@ -171,7 +204,7 @@ def __init__(
self._browser_tree_logic.on_lock_state_changed = self._browser_panel.set_tree_enabled
self._browser_tree_logic.on_favorite_changed = on_favorite_changed
self._browser_tree_logic.on_search_update_needed = self._browser_panel.update_tree_visibility
- self._browser_tree_logic.on_autoplay_error = self._on_browser_autoplay_error
+ self._browser_tree_logic.on_autoplay_error = self._on_preview_error
self._browser_panel.set_collapse_handler(self._on_browser_collapse_changed)
self._browser_panel.on_favorites_filter_changed = self._on_browser_favorites_filter_changed
self._reconstruction_player_logic = PlayerLogic(
@@ -192,18 +225,26 @@ def __init__(
)
self._reconstruction_plot_panel: GUIReconstructionPlotPanel = GUIReconstructionPlotPanel(
layout_graphs=layout.graphs,
+ channel_colors=layout.channel_colors,
initial_collapsed=session_manager.is_card_collapsed(TAG_RECONSTRUCTIONS_RECONSTRUCTION_PANEL_PLOT),
language_manager=language_manager,
status_bar=status_bar,
)
+ self._reconstruction_stems_panel: GUIReconstructionStemsPanel = GUIReconstructionStemsPanel(
+ stems_layout=layout.stems,
+ language_manager=language_manager,
+ status_bar=status_bar,
+ initial_collapsed=session_manager.is_card_collapsed(TAG_RECONSTRUCTIONS_RECONSTRUCTION_PANEL_STEMS),
+ )
self._reconstruction_audio_panel.set_collapse_handler(self._on_card_collapse_changed)
self._reconstruction_plot_panel.set_collapse_handler(self._on_card_collapse_changed)
+ self._reconstruction_stems_panel.set_collapse_handler(self._on_card_collapse_changed)
self._reconstruction_player_logic.on_position_changed = self._reconstruction_plot_panel.set_playback_position
self._reconstruction_panel_logic: ReconstructionPanelLogic = ReconstructionPanelLogic(
session_manager,
reconstruction_manager,
export_service,
- tracker_backends,
+ export_backends,
)
self._reconstruction_instruments_panel: GUIReconstructionInstrumentsPanel = GUIReconstructionInstrumentsPanel(
pitch_stepper_style=layout.pitch_stepper_style,
@@ -212,13 +253,21 @@ def __init__(
layout_graphs=layout.graphs,
language_manager=language_manager,
status_bar=status_bar,
+ key_router=key_router,
+ tab_active=tab_active,
initial_collapsed=session_manager.is_card_collapsed(TAG_RECONSTRUCTIONS_INSTRUMENTS_PANEL),
)
self._reconstruction_instruments_panel.set_collapse_handler(self._on_instruments_collapse_changed)
self._reconstruction_instruments_logic: ReconstructionInstrumentsLogic = ReconstructionInstrumentsLogic(
- reconstruction_manager,
+ self._instrument_editor,
scheduling=layout.scheduling,
)
+ self._instrument_audition_logic: InstrumentAuditionLogic = InstrumentAuditionLogic(
+ self._instrument_editor,
+ project_controller,
+ session_manager,
+ audio_device_manager,
+ )
self._browser_panel.on_refresh_tree = self._browser_logic.refresh_tree
self._browser_panel.on_load_reconstruction = on_load_reconstruction_with_confirmation
@@ -226,10 +275,13 @@ def __init__(
self._browser_panel.on_directory_remove_requested = self._request_remove_directory
self._reconstruction_audio_panel.on_audio_source_changed = self._reconstruction_panel_logic.set_audio_source
- self._reconstruction_plot_panel.on_generators_changed = self._reconstruction_panel_logic.set_selected_generators
+ self._reconstruction_plot_panel.on_channels_changed = self._reconstruction_panel_logic.set_selected_channels
+ self._reconstruction_stems_panel.on_stem_channels_changed = self._reconstruction_panel_logic.set_stem_channels
+ self._reconstruction_stems_panel.on_stem_remove_requested = self._request_remove_stem
self._browser_panel.on_locate_original_audio = self._original_audio_locator.locate
self._reconstruction_panel_logic.on_view_changed = self._update_reconstruction_view
+ self._reconstruction_panel_logic.on_stems_view_changed = self._reconstruction_stems_panel.update_view
self._reconstruction_panel_logic.on_audio_data_changed = self._on_audio_data_changed
self._reconstruction_panel_logic.on_waveform_load_changed = self._reconstruction_plot_panel.load_waveform_data
self._reconstruction_panel_logic.on_waveform_update_changed = (
@@ -239,7 +291,6 @@ def __init__(
self._reconstruction_panel_logic.on_waveform_source_changed = (
self._reconstruction_plot_panel.set_waveform_top_source
)
- self._reconstruction_panel_logic.on_open_export_instrument_dialog = self._open_export_instrument_dialog
self._reconstruction_panel_logic.on_open_export_instruments_dialog = self._open_export_instruments_dialog
self._reconstruction_panel_logic.on_open_export_wav_dialog = self._open_export_wav_dialog
self._reconstruction_panel_logic.on_locate_audio_not_found = lambda path: dialogs.show_file_not_found(
@@ -257,33 +308,41 @@ def __init__(
on_reconstruction_instrument_updated
)
- self._reconstruction_instruments_panel.on_instrument_export = (
- self._reconstruction_panel_logic.request_export_instrument_dialog
- )
+ self._reconstruction_instruments_panel.on_instrument_export = self._export_instrument
self._reconstruction_instruments_panel.on_reconstruction_instrument_hovered = (
self._reconstruction_plot_panel.set_overlay
)
self._reconstruction_instruments_panel.on_pitch_value_changed = (
self._reconstruction_instruments_logic.handle_pitch_value_changed
)
- self._reconstruction_instruments_panel.on_bar_data_changed = (
- self._reconstruction_instruments_logic.handle_bar_point_clicked
+ self._reconstruction_instruments_panel.on_envelope_changed = (
+ self._reconstruction_instruments_logic.handle_envelope_changed
)
- self._reconstruction_instruments_panel.on_raw_data_changed = (
- self._reconstruction_instruments_logic.handle_raw_data_changed
+ self._reconstruction_instruments_panel.on_audition_requested = self._instrument_audition_logic.sound
+ self._reconstruction_instruments_panel.on_audition_generator_changed = (
+ self._instrument_audition_logic.set_generator
)
+ self._instrument_audition_logic.on_audition_error = self._on_preview_error
+ self._instrument_audition_logic.on_waveform_changed = self._reconstruction_plot_panel.update_instrument_view
+ self._instrument_audition_logic.on_position_changed = self._reconstruction_plot_panel.set_playback_position
+ self._reconstruction_instruments_logic.on_display_refreshed = self._instrument_audition_logic.refresh
def _on_export_result(self, result: ExportResult) -> None:
+ """Reports a finished export in the words of the artefact it produced.
+
+ A run long enough to watch held a window while it ran, and DearPyGui carries one modal at
+ a time, so the report waits for the frame that draws the screen without it.
+ """
messages = self._export_messages
match result:
case ExportSuccess(kind=ExportKind.WAV, filepath=fp):
- self._dialogs.show_message_with_path(messages.wav_title, messages.wav_success, fp)
+ self._present_path(messages.wav_title, messages.wav_success, fp)
case ExportSuccess(
kind=ExportKind.INSTRUMENT,
filepath=fp,
truncation=truncation,
):
- self._dialogs.show_message_with_path(
+ self._present_path(
messages.status_title,
self._export_message(
messages.instrument_success,
@@ -297,7 +356,7 @@ def _on_export_result(self, result: ExportResult) -> None:
filepath=fp,
truncation=truncation,
):
- self._dialogs.show_message_with_path(
+ self._present_path(
messages.status_title,
self._export_message(
messages.instruments_success,
@@ -307,11 +366,19 @@ def _on_export_result(self, result: ExportResult) -> None:
fp,
)
case ExportError(kind=ExportKind.WAV, exception=exception):
- self._dialogs.show_error(exception, messages.wav_failed)
+ self._present_error(exception, messages.wav_failed)
case ExportError(kind=ExportKind.INSTRUMENT, exception=exception):
- self._dialogs.show_error(exception, messages.instrument_failed)
+ self._present_error(exception, messages.instrument_failed)
case ExportError(kind=ExportKind.SAMPLE, exception=exception):
- self._dialogs.show_error(exception, messages.instruments_failed)
+ self._present_error(exception, messages.instruments_failed)
+
+ def _present_path(self, title: str, message: str, filepath: Path) -> None:
+ """Reports a written file once the frame the export window left the screen in has finished."""
+ FrameCallbackManager.set_frame_callback(partial(self._dialogs.show_message_with_path, title, message, filepath))
+
+ def _present_error(self, exception: Exception, message: str) -> None:
+ """Reports a failure once the frame the export window left the screen in has finished."""
+ FrameCallbackManager.set_frame_callback(partial(self._dialogs.show_error, exception, message))
def _export_message(
self,
@@ -347,52 +414,32 @@ def _update_reconstruction_view(
self._reconstruction_audio_panel.update_view(view_model)
self._reconstruction_plot_panel.update_view(view_model)
- 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.
+ def _export_instrument(self, channel_name: ChannelName) -> None:
+ """Writes the instrument the tab's ``channel_name`` tab holds, wherever it is asked for.
- 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.
+ An instrument reaches a file the same way whichever surface asked for it, so the whole
+ gesture from here on belongs to the shared exporter; what the tab contributes is which
+ instrument it has in front of it. A voice written by hand is one the pool holds, so it is
+ written by voice and the sequencer's voice menu writes the same file for it.
"""
- filepath = save_file_dialog(
- title=self._language_manager["reconstructions.instruments.title.export_instrument_dialog"],
- initial_directory=default_path,
- default_filename=default_filename,
- 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.
+ instrument = self._instrument_editor.instrument
+ if instrument is not None:
+ self._instrument_exports.request_voice(instrument.id, None)
+ return
- 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
- )
-
- @ignore_none_path
- def _handle_export_instrument(
- self,
- filepath: Path,
- generator_name: GeneratorName,
- ) -> None:
- self._reconstruction_panel_logic.handle_export_instrument_confirmed(filepath, generator_name)
+ exportable = self._reconstruction_panel_logic.exportable_instrument(channel_name)
+ if exportable is not None:
+ self._instrument_exports.request(exportable.source, exportable.name)
def _open_export_instruments_dialog(
self,
default_filename: str,
default_path: str,
- tracker_format: TrackerFormat,
+ export_format: ExportFormat,
) -> 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
+ The format 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.
"""
@@ -400,35 +447,47 @@ def _open_export_instruments_dialog(
title=self._language_manager["reconstructions.instruments.title.export_instruments_dialog"],
initial_directory=default_path,
default_filename=default_filename,
- filters=(self._tracker_filter(tracker_format, ExportScope.SAMPLE),),
+ filters=(self._export_filter(export_format, ExportScope.SAMPLE),),
)
- self._handle_export_instruments(destination, tracker_format)
+ self._handle_export_instruments(destination, export_format)
- def _tracker_filter(
+ def _export_filter(
self,
- tracker_format: TrackerFormat,
+ export_format: ExportFormat,
scope: ExportScope,
) -> FileFilter:
- """The type ``tracker_format`` writes ``scope`` files as, named after that tracker."""
+ """The type ``export_format`` writes ``scope`` files as, named after that format."""
return FileFilter.for_extensions(
- self._instrument_filter_names[tracker_format],
- [self._tracker_backends[tracker_format].extension(scope)],
+ self._instrument_filter_names[export_format],
+ [self._export_backends[export_format].extension(scope)],
)
@ignore_none_path
def _handle_export_instruments(
self,
destination: Path,
- tracker_format: TrackerFormat,
+ export_format: ExportFormat,
) -> None:
- self._reconstruction_panel_logic.handle_export_instruments_confirmed(destination, tracker_format)
+ self._reconstruction_panel_logic.handle_export_instruments_confirmed(
+ destination,
+ export_format,
+ )
- def _open_export_wav_dialog(self, default_filename: str, default_path: str) -> None:
+ 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,
- filters=(FileFilter.for_extensions(self._language_manager["global.dialog.filter.wave"], [EXT_FILE_WAVE]),),
+ filters=(
+ FileFilter.for_extensions(
+ self._language_manager["global.dialog.filter.wave"],
+ [EXT_FILE_WAVE],
+ ),
+ ),
)
self._handle_export_wav(filepath)
@@ -475,10 +534,12 @@ def create_tab(self) -> None:
self._sync_instruments_width()
def _build_reconstruction_column(self, parent: str) -> None:
- """Stacks the audio and plot cards down the centre column."""
+ """Stacks the audio, plot, and stems cards down the center column."""
self._reconstruction_audio_panel.create_panel(parent)
dpg.add_spacer(height=self._geometry.panel_gap, parent=parent)
self._reconstruction_plot_panel.create_panel(parent)
+ dpg.add_spacer(height=self._geometry.panel_gap, parent=parent)
+ self._reconstruction_stems_panel.create_panel(parent)
def _on_card_collapse_changed(
self,
@@ -565,13 +626,30 @@ def save_browser_shape(self) -> None:
self._browser_panel.expanded_rows,
)
- def repaint_browser_favorites(self, nodes: Sequence[FileSystemNode]) -> None:
+ def repaint_browser_favorites(
+ self,
+ nodes: Sequence[FileSystemNode],
+ ) -> None:
self._browser_panel.update_favorite_indicators(nodes)
def display_reconstruction(self) -> None:
+ self._instrument_editor.release_instrument()
self._reconstruction_panel_logic.display_reconstruction()
self._reconstruction_instruments_logic.update_display()
+ def edit_instrument(self, voice_id: str) -> None:
+ """Puts an instrument in front of the tab, closing whatever reconstruction it held.
+
+ The tab describes one voice at a time — an instrument stands on no recording, so the waveform,
+ the plot and the stems beside the instruments panel have nothing of it to draw.
+ """
+ self._instrument_editor.edit_instrument(voice_id)
+ self._reconstruction_instruments_logic.update_display()
+
+ def release_instrument(self) -> None:
+ """Lets go of the instrument the tab held, which is what opening a reconstruction does."""
+ self._instrument_editor.release_instrument()
+
def close_reconstruction(self) -> None:
self._reconstruction_panel_logic.close_reconstruction()
self._reconstruction_instruments_logic.update_display()
@@ -586,6 +664,37 @@ def _request_remove_reconstruction(self, filepath: Path) -> None:
path=filepath,
)
+ def _request_remove_stem(self, stem_id: int) -> None:
+ """Asks before a recording leaves the loaded reconstruction, naming the file it stands as."""
+ row = self._reconstruction_stems_panel.stems_list.row(str(stem_id))
+ if row is None:
+ return
+
+ self._dialogs.show_confirmation(
+ tag=TAG_RECONSTRUCTIONS_RECONSTRUCTION_DIALOG_REMOVE_STEM_CONFIRMATION,
+ title=self._language_manager["reconstructions.reconstruction.title.remove_stem_dialog"],
+ message=self._language_manager["reconstructions.reconstruction.message.remove_stem_message"],
+ on_confirm=lambda: self._remove_stem(stem_id, row.name),
+ ok_label=self._lbl_remove,
+ path=row.path,
+ )
+
+ def _remove_stem(self, stem_id: int, name: str) -> None:
+ """Takes the recording out of the open document and hands the edit on to be recorded."""
+ reconstruction_data = self._reconstruction_manager.current_reconstruction
+ if reconstruction_data is None:
+ return
+
+ self._on_reconstruction_stem_removed(
+ StemRemoval(
+ reconstruction=without_stem(
+ reconstruction_data.reconstruction,
+ stem_id,
+ ),
+ stem_name=name,
+ )
+ )
+
def _request_remove_directory(self, directory: Path) -> None:
self._dialogs.show_confirmation(
tag=TAG_RECONSTRUCTIONS_BROWSER_DIALOG_REMOVE_DIRECTORY_CONFIRMATION,
@@ -635,9 +744,9 @@ def update_reconstruction(self) -> None:
def set_reconstruction_dimmed(self, dimmed: bool) -> None:
self._reconstruction_plot_panel.set_reconstruction_dimmed(dimmed)
- def toggle_generator(self, generator: GeneratorName) -> None:
- """Switches one generator's slice in and out of the waveform and of what plays."""
- self._reconstruction_plot_panel.toggle_generator(generator)
+ def toggle_channel(self, channel: ChannelName) -> None:
+ """Switches one channel's slice in and out of the waveform and of what plays."""
+ self._reconstruction_plot_panel.toggle_channel(channel)
@property
def player(self) -> AudioPlayerProtocol:
@@ -646,10 +755,19 @@ 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, tracker_format: TrackerFormat) -> None:
- self._reconstruction_panel_logic.request_export_instruments_dialog(tracker_format)
+ def request_export_instruments_dialog(
+ self,
+ export_format: ExportFormat,
+ ) -> None:
+ self._reconstruction_panel_logic.request_export_instruments_dialog(export_format)
- def _on_browser_autoplay_error(self, exception: Exception) -> None:
+ def _on_preview_error(self, exception: Exception) -> None:
+ """Reports a preview the audio device refused, whichever of the tab's previews asked for it.
+
+ A browser autoplay and an instrument audition both sound on demand, so both report the
+ same way: on the frame after the one that failed, which leaves the gesture that started it
+ finished before a dialog is raised.
+ """
FrameCallbackManager.set_frame_callback(lambda: self._dialogs.show_error(exception))
def _on_audio_data_changed(self, audio_data: Optional[AudioData]) -> None:
diff --git a/src/sampletones_application/coordinators/tabs/sequencer.py b/src/sampletones_application/coordinators/tabs/sequencer.py
deleted file mode 100644
index 5d16f7c12..000000000
--- a/src/sampletones_application/coordinators/tabs/sequencer.py
+++ /dev/null
@@ -1,1522 +0,0 @@
-from pathlib import Path
-from typing import Callable, Optional, ParamSpec, Sequence, Tuple, Union
-
-import dearpygui.dearpygui as dpg
-
-from sampletones_application.categories.elements.sequencer import (
- SequencerHistoryElements,
-)
-from sampletones_application.categories.hierarchy import Page, Panel, Tab, TextType
-from sampletones_application.categories.manager import LanguageManager
-from sampletones_application.config.managers.config import ConfigManager
-from sampletones_application.config.managers.session import SessionManager
-from sampletones_application.constants.playback import FollowMode
-from sampletones_application.coordinators.edit.protocol import EditSurfaceProtocol
-from sampletones_application.coordinators.original_audio import OriginalAudioLocator
-from sampletones_application.coordinators.playback.guard import GuardedPlayer
-from sampletones_application.coordinators.playback.protocol import AudioPlayerProtocol
-from sampletones_application.logic.history.action import HistoryAction
-from sampletones_application.logic.history.manager import HistoryManager
-from sampletones_application.logic.history.transaction import CoalesceKey
-from sampletones_application.logic.project.controller import ProjectController
-from sampletones_application.logic.reconstruction.browser.manager import BrowserManager
-from sampletones_application.logic.sequencer.browser import SequencerBrowserLogic
-from sampletones_application.logic.sequencer.channels import SequencerChannelsLogic
-from sampletones_application.logic.sequencer.clipboard import (
- OrderBlockText,
- ParsedBlockCache,
- ProjectSampleDirectory,
- SequencerClipboard,
- TrackerBlockText,
-)
-from sampletones_application.logic.sequencer.history_detail import (
- SequencerHistoryDetail,
-)
-from sampletones_application.logic.sequencer.order import (
- OrderBlock,
- OrderBlockReader,
- OrderBlockWriter,
- SequencerOrderLogic,
-)
-from sampletones_application.logic.sequencer.playback.playhead import (
- remap_after_insert,
- remap_after_move,
- remap_after_remove,
-)
-from sampletones_application.logic.sequencer.playback.song_player import SongPlayerLogic
-from sampletones_application.logic.sequencer.playback.synthesizer import RowSynthesizer
-from sampletones_application.logic.sequencer.samples import SequencerSamplesLogic
-from sampletones_application.logic.sequencer.tracker import (
- SequencerTrackerLogic,
- TrackerBlock,
- TrackerBlockReader,
- TrackerBlockWriter,
- TrackerRegionAdjuster,
-)
-from sampletones_application.logic.shared.tree import TreeLogic
-from sampletones_application.parameters.sequencer import SequencerTabParameters
-from sampletones_application.services.song_player.player import SongPlayerService
-from sampletones_application.tags.compose import compose_tag
-from sampletones_application.tags.general import (
- SUF_PANEL_CENTER,
- SUF_PANEL_LEFT,
- SUF_PANEL_RIGHT,
- TAG_GLOBAL_DIALOG_NO_PROJECT_OPEN,
- TAG_GLOBAL_TAB_SEQUENCER,
- TAG_GLOBAL_TABS,
- TAG_GLOBAL_THEME_DEFAULT,
- TAG_GLOBAL_THEME_PANEL_GROUND,
- TAG_GLOBAL_THEME_PANEL_SURFACE,
-)
-from sampletones_application.tags.sequencer import (
- TAG_SEQUENCER_BROWSER_DIALOG_FREQUENCY,
- TAG_SEQUENCER_BROWSER_PANEL,
- TAG_SEQUENCER_HISTORY_PANEL,
- TAG_SEQUENCER_INSTRUMENTS_DIALOG_REMOVE,
- TAG_SEQUENCER_INSTRUMENTS_PANEL,
- TAG_SEQUENCER_MODULE_DIALOG_NES_FREQUENCY,
- TAG_SEQUENCER_MODULE_PANEL,
- TAG_SEQUENCER_ORDER_WINDOW_ORDER_CARD,
- TAG_SEQUENCER_TRACKER_PANEL,
-)
-from sampletones_application.ui.elements.layout.columns import ColumnSpec, TabColumns
-from sampletones_application.ui.elements.layout.responsive import expanded_side_width
-from sampletones_application.ui.elements.status import GUIStatusBar
-from sampletones_application.ui.panels.sequencer.browser import GUISequencerBrowserPanel
-from sampletones_application.ui.panels.sequencer.history import GUISequencerHistoryPanel
-from sampletones_application.ui.panels.sequencer.module import GUISequencerModulePanel
-from sampletones_application.ui.panels.sequencer.order import GUISequencerOrderPanel
-from sampletones_application.ui.panels.sequencer.samples import GUISequencerSamplesPanel
-from sampletones_application.ui.panels.sequencer.tracker import GUISequencerTrackerPanel
-from sampletones_application.ui.themes.registry import ThemeRegistry
-from sampletones_application.utils.gui.clipboard import (
- SystemTextClipboard,
- TextClipboard,
-)
-from sampletones_application.utils.gui.dialogs import DialogsRenderer
-from sampletones_application.utils.gui.dpg import dpg_configure_item
-from sampletones_application.utils.gui.frame import FrameCallbackManager
-from sampletones_application.utils.gui.keyboard import ActivePredicate, KeyRouter
-from sampletones_application.utils.gui.shortcuts.source import ShortcutSource
-from sampletones_application.view_model.sequencer.channels import (
- SequencerChannelsViewModel,
-)
-from sampletones_application.view_model.sequencer.history import (
- HistoryEntryViewModel,
- HistoryViewModel,
-)
-from sampletones_application.view_model.sequencer.region import (
- OrderCell,
- OrderRegion,
- TrackerCell,
- TrackerRegion,
-)
-from sampletones_application.view_model.sequencer.samples import (
- SequencerSamplesViewModel,
-)
-from sampletones_application.view_model.sequencer.settings import (
- SequencerSettingsViewModel,
-)
-from sampletones_application.view_model.sequencer.song_player import SongPlayerViewModel
-from sampletones_application.view_model.shared.history import (
- HistoryDetail,
- HistoryDetailSegment,
- HistoryDetailWordSegment,
-)
-from sampletones_core.audio import AudioDeviceManager
-from sampletones_core.constants.enums import FeatureKey, GeneratorName
-from sampletones_core.project.song_position import SongPosition
-from sampletones_core.reconstructions import Reconstruction
-from sampletones_core.structures.tree import FileSystemNode
-from sampletones_shared.exceptions import SampleToNESError
-from sampletones_shared.logger import logger
-from sampletones_shared.types.callback import StringCallback, VoidCallback
-
-_UndoableParams = ParamSpec("_UndoableParams")
-
-_LEFT_COLUMN_TAG = compose_tag(TAG_GLOBAL_TAB_SEQUENCER, SUF_PANEL_LEFT)
-_CENTER_COLUMN_TAG = compose_tag(TAG_GLOBAL_TAB_SEQUENCER, SUF_PANEL_CENTER)
-_RIGHT_COLUMN_TAG = compose_tag(TAG_GLOBAL_TAB_SEQUENCER, SUF_PANEL_RIGHT)
-
-
-class SequencerTabCoordinator:
- def __init__(
- self,
- config_manager: ConfigManager,
- session_manager: SessionManager,
- audio_device_manager: AudioDeviceManager,
- key_router: KeyRouter,
- shortcut_source: ShortcutSource,
- browser_manager: BrowserManager,
- project_controller: ProjectController,
- history: HistoryManager,
- original_audio_locator: OriginalAudioLocator,
- *,
- tab_active: ActivePredicate,
- layout: SequencerTabParameters,
- language_manager: LanguageManager,
- dialogs: DialogsRenderer,
- status_bar: GUIStatusBar,
- on_edit_sample_requested: StringCallback,
- on_favorite_changed: Callable[[FileSystemNode], None],
- on_sample_reconstruction_replaced: Callable[[str, Reconstruction], None],
- on_tab_switch: Callable[[Tab], None],
- on_nes_frequency_changed: Callable[[int], None],
- on_channels_changed: VoidCallback,
- ) -> None:
- self._project_controller = project_controller
- self._session_manager = session_manager
- self._history = history
- self._original_audio_locator = original_audio_locator
- self._on_edit_sample_requested = on_edit_sample_requested
- self._on_favorite_changed = on_favorite_changed
- self._on_sample_reconstruction_replaced = on_sample_reconstruction_replaced
- self._on_tab_switch = on_tab_switch
- self._on_nes_frequency_changed = on_nes_frequency_changed
- self._on_channels_changed = on_channels_changed
- self._language_manager = language_manager
- self._dialogs = dialogs
-
- self._msg_no_project = language_manager["global.dialog.message.no_project_open"]
- self._ttl_no_project = language_manager["global.dialog.title.no_project_open"]
- self._nes_frequency_change_acknowledged: bool = False
- self._playing_position: Optional[SongPosition] = None
- self._geometry = layout.geometry
- self._side_panel_count: int
- self._instruments_width = layout.right_column_width
- self._right_height = layout.right_column_height
- self._history_expanded_height = layout.history_height
- self._history_collapsed_footprint = layout.header_bar_height + 2 * self._geometry.panel_gap
- self._inter_card_gap = self._stacked_card_gap()
-
- self._sequencer_browser_logic: SequencerBrowserLogic = SequencerBrowserLogic(
- config_manager,
- browser_manager,
- project_controller,
- )
- self._sequencer_tree_logic: TreeLogic = TreeLogic(
- session_manager,
- audio_device_manager,
- scheduling=layout.scheduling,
- )
- self._sequencer_browser_panel: GUISequencerBrowserPanel = GUISequencerBrowserPanel(
- self._sequencer_browser_logic.tree,
- self._sequencer_tree_logic,
- scheduling=layout.scheduling,
- language_manager=language_manager,
- status_bar=status_bar,
- colors=layout.tree_colors,
- initial_collapsed=session_manager.is_card_collapsed(TAG_SEQUENCER_BROWSER_PANEL),
- initial_favorites_only=session_manager.is_favorites_filter_active(TAG_SEQUENCER_BROWSER_PANEL),
- initial_expanded_rows=session_manager.expanded_rows(TAG_SEQUENCER_BROWSER_PANEL),
- )
- self._sequencer_tracker_logic: SequencerTrackerLogic = SequencerTrackerLogic(project_controller)
- self._sequencer_order_logic: SequencerOrderLogic = SequencerOrderLogic(project_controller)
- self._clipboard: SequencerClipboard = SequencerClipboard()
- self._system_clipboard: TextClipboard = SystemTextClipboard()
- self._tracker_block_text: TrackerBlockText = TrackerBlockText(
- samples=ProjectSampleDirectory(project_controller),
- )
- self._order_block_text: OrderBlockText = OrderBlockText()
- self._tracker_text_cache: ParsedBlockCache[TrackerBlock] = ParsedBlockCache(self._tracker_block_text.parse)
- self._order_text_cache: ParsedBlockCache[OrderBlock] = ParsedBlockCache(self._order_block_text.parse)
- self._tracker_block_reader: TrackerBlockReader = TrackerBlockReader(self._sequencer_tracker_logic)
- self._tracker_block_writer: TrackerBlockWriter = TrackerBlockWriter(self._sequencer_tracker_logic)
- self._tracker_region_adjuster: TrackerRegionAdjuster = TrackerRegionAdjuster(self._sequencer_tracker_logic)
- self._order_block_reader: OrderBlockReader = OrderBlockReader(self._sequencer_order_logic)
- self._order_block_writer: OrderBlockWriter = OrderBlockWriter(self._sequencer_order_logic)
- self._sequencer_samples_logic: SequencerSamplesLogic = SequencerSamplesLogic(
- project_controller,
- session_manager,
- audio_device_manager,
- scheduling=layout.scheduling,
- )
- self._sequencer_channels_logic: SequencerChannelsLogic = SequencerChannelsLogic()
- self._song_player_logic: SongPlayerLogic = SongPlayerLogic(
- audio_device_manager,
- project_controller,
- session_manager,
- service=SongPlayerService(
- audio_device_manager,
- RowSynthesizer(
- project_controller,
- config_manager.config,
- active_channels=lambda: self._sequencer_channels_logic.active_channels,
- sample_rate=lambda: audio_device_manager.sample_rate,
- ),
- should_loop=lambda: session_manager.loop_song,
- master_gain=lambda: session_manager.master_gain,
- ),
- )
- self._guarded_player = GuardedPlayer(
- self._song_player_logic,
- dialogs=dialogs,
- error_message=language_manager["global.player.message.audio_playback_error"],
- )
- self._sequencer_tracker_panel: GUISequencerTrackerPanel = GUISequencerTrackerPanel(
- self._sequencer_tracker_logic.settings,
- layout=layout.sequencer,
- initial_collapsed=session_manager.is_card_collapsed(TAG_SEQUENCER_TRACKER_PANEL),
- language_manager=language_manager,
- key_router=key_router,
- tab_active=tab_active,
- shortcut_source=shortcut_source,
- )
- self._sequencer_module_panel: GUISequencerModulePanel = GUISequencerModulePanel(
- self._sequencer_tracker_logic.settings,
- layout=layout.sequencer,
- inputs=layout.inputs,
- initial_collapsed=session_manager.is_card_collapsed(TAG_SEQUENCER_MODULE_PANEL),
- language_manager=language_manager,
- status_bar=status_bar,
- )
- self._sequencer_order_panel: GUISequencerOrderPanel = GUISequencerOrderPanel(
- layout=layout.sequencer,
- plus_minus_layout=layout.plus_minus,
- initial_collapsed=session_manager.is_card_collapsed(TAG_SEQUENCER_ORDER_WINDOW_ORDER_CARD),
- language_manager=language_manager,
- key_router=key_router,
- tab_active=tab_active,
- shortcut_source=shortcut_source,
- )
- self._sequencer_samples_panel: GUISequencerSamplesPanel = GUISequencerSamplesPanel(
- layout=layout.sequencer,
- detail_color=layout.muted_color,
- initial_collapsed=session_manager.is_card_collapsed(TAG_SEQUENCER_INSTRUMENTS_PANEL),
- language_manager=language_manager,
- key_router=key_router,
- tab_active=tab_active,
- shortcut_source=shortcut_source,
- )
- self._sequencer_history_panel: GUISequencerHistoryPanel = GUISequencerHistoryPanel(
- layout=layout.sequencer,
- feature_colors=layout.feature_colors,
- initial_collapsed=session_manager.is_card_collapsed(TAG_SEQUENCER_HISTORY_PANEL),
- language_manager=language_manager,
- status_bar=status_bar,
- )
- self._history_detail: SequencerHistoryDetail = SequencerHistoryDetail(
- self._sequencer_tracker_logic,
- self._sequencer_samples_logic,
- )
-
- self._wire_callbacks()
-
- def _wire_callbacks(self) -> None:
- """Connects every panel and logic object this tab owns to the handler that serves it."""
- self._wire_collapse_handlers()
- self._wire_module_callbacks()
- self._wire_tracker_callbacks()
- self._wire_channels_callbacks()
- self._wire_order_callbacks()
- self._wire_block_callbacks()
- self._wire_samples_callbacks()
- self._wire_browser_callbacks()
- self._wire_playback_callbacks()
- self._wire_project_callbacks()
- self._wire_history()
-
- def _wire_collapse_handlers(self) -> None:
- for panel in (
- self._sequencer_order_panel,
- self._sequencer_tracker_panel,
- self._sequencer_module_panel,
- self._sequencer_samples_panel,
- self._sequencer_history_panel,
- ):
- panel.set_collapse_handler(self._on_card_collapse_changed)
-
- def _wire_module_callbacks(self) -> None:
- self._sequencer_module_panel.on_nes_frequency = self._request_nes_frequency_change
- self._sequencer_module_panel.on_rows_per_pattern = self._undoable(
- HistoryAction.SET_ROWS_PER_PATTERN,
- self._sequencer_tracker_logic.set_rows_per_pattern,
- detail=self._history_detail.value,
- coalesce=self._module_setting_key,
- )
- self._sequencer_module_panel.on_tempo = self._undoable(
- HistoryAction.SET_TEMPO,
- self._sequencer_tracker_logic.set_tempo,
- detail=self._history_detail.value,
- coalesce=self._module_setting_key,
- )
- self._sequencer_module_panel.on_speed = self._undoable(
- HistoryAction.SET_SPEED,
- self._sequencer_tracker_logic.set_speed,
- detail=self._history_detail.value,
- coalesce=self._module_setting_key,
- )
-
- def _wire_tracker_callbacks(self) -> None:
- self._sequencer_tracker_panel.on_clear_row = self._undoable(
- HistoryAction.CLEAR_ROW,
- self._sequencer_tracker_logic.clear_cell,
- detail=self._history_detail.clear_row,
- )
- self._sequencer_tracker_panel.on_clear_subcolumn = self._undoable(
- HistoryAction.CLEAR_SUBCOLUMN,
- self._sequencer_tracker_logic.clear_cell_subcolumn,
- detail=self._history_detail.clear_subcolumn,
- )
- self._sequencer_tracker_panel.on_set_row = self._undoable(
- HistoryAction.EDIT_ROW,
- self._sequencer_tracker_logic.write_cell,
- detail=self._history_detail.edit_row,
- coalesce=self._edit_row_key,
- )
- self._sequencer_tracker_panel.on_set_note_off = self._undoable(
- HistoryAction.NOTE_OFF,
- self._sequencer_tracker_logic.cut_note,
- detail=self._history_detail.note_off,
- coalesce=self._cell_key,
- )
- self._sequencer_tracker_panel.on_cell_selected = self._on_tracker_cell_focused
- self._sequencer_tracker_panel.on_play_from_row = self._on_tracker_play_from_row
- self._sequencer_tracker_panel.on_play_from_frame = self.play_from_current_frame
- self._sequencer_tracker_panel.on_adjust_transpose = self._undoable(
- HistoryAction.ADJUST_TRANSPOSE,
- self._tracker_region_adjuster.adjust_transpose,
- detail=self._history_detail.adjust_transpose,
- coalesce=self._adjustment_key,
- )
- self._sequencer_tracker_panel.on_adjust_volume = self._undoable(
- HistoryAction.ADJUST_VOLUME,
- self._tracker_region_adjuster.adjust_volume,
- detail=self._history_detail.adjust_volume,
- coalesce=self._adjustment_key,
- )
- self._sequencer_tracker_logic.on_settings_changed = self._on_settings_changed
- self._sequencer_tracker_logic.on_tracker_changed = self._sequencer_tracker_panel.update_tracker
- self._sequencer_tracker_logic.on_frame_changed = self._sequencer_order_panel.select_position
-
- def _wire_channels_callbacks(self) -> None:
- """Connects the tracker's column headers and the order table's row labels to the mute set
- the song player reads.
-
- Both tables name the same channels and switch the same set, so each panel's hooks reach the
- channels logic directly and both show every change. Muting is a monitoring gesture, so these
- hooks record no history entry.
- """
- self._sequencer_channels_logic.on_channels_changed = self._show_channels
- for panel in (self._sequencer_tracker_panel, self._sequencer_order_panel):
- panel.on_channel_mute_toggled = self._sequencer_channels_logic.toggle
- panel.on_channel_soloed = self._sequencer_channels_logic.solo
- panel.on_channels_toggled = self._sequencer_channels_logic.toggle_all
- panel.on_channels_muted = self._sequencer_channels_logic.mute_all
- panel.on_channels_unmuted = self._sequencer_channels_logic.unmute_all
-
- def _show_channels(self, view_model: SequencerChannelsViewModel) -> None:
- """Shows the mute set in both tables and in the menu bar, so a channel reads the same
- wherever it appears.
-
- The menu bar sits above this tab and rebuilds its own state, so it is handed the change
- as a signal and reads the mute set back through :attr:`channels`.
- """
- self._sequencer_tracker_panel.update_channels(view_model)
- self._sequencer_order_panel.update_channels(view_model)
- self._on_channels_changed()
-
- @property
- def channels(self) -> SequencerChannelsViewModel:
- """The mute set the tables show, for the menu bar that lists the same channels."""
- return self._sequencer_channels_logic.build_channels()
-
- def toggle_channel(self, generator: GeneratorName) -> None:
- """Flips one channel between audible and silent, the menu's per-channel gesture."""
- self._sequencer_channels_logic.toggle(generator)
-
- def unmute_all_channels(self) -> None:
- """Returns every channel to audible, the menu's whole-mix gesture."""
- self._sequencer_channels_logic.unmute_all()
-
- def set_follow_mode(self, mode: FollowMode) -> None:
- """Chooses how far the view chases the playhead, the menu's and keyboard's gesture.
-
- The player holds the setting and emits a view as it changes, which is what settles the
- grid's following and the menu's mark together.
- """
- self._song_player_logic.set_follow_mode(mode)
-
- def _wire_order_callbacks(self) -> None:
- self._sequencer_order_logic.on_order_changed = self._sequencer_order_panel.update_order
- self._sequencer_order_panel.on_frame_selected = self._on_order_frame_selected
- self._sequencer_order_panel.on_remove_requested = self._undoable(
- HistoryAction.REMOVE_FRAME,
- self._on_order_remove,
- detail=self._history_detail.remove_frame,
- )
- self._sequencer_order_panel.on_duplicate_requested = self._undoable(
- HistoryAction.DUPLICATE_FRAME,
- self._on_order_duplicate,
- detail=self._history_detail.copy_frame,
- )
- self._sequencer_order_panel.on_clone_requested = self._undoable(
- HistoryAction.CLONE_FRAME,
- self._on_order_clone,
- detail=self._history_detail.copy_frame,
- )
- self._sequencer_order_panel.on_insert_requested = self._undoable(
- HistoryAction.ADD_FRAME,
- self._on_order_insert,
- detail=self._history_detail.add_frame,
- )
- self._sequencer_order_panel.on_clear_requested = self._undoable(
- HistoryAction.CLEAR_FRAME,
- self._on_order_clear,
- detail=self._history_detail.clear_frame,
- )
- self._sequencer_order_panel.on_play_from_requested = self._on_order_play_from
- self._sequencer_order_panel.on_move_requested = self._undoable(
- HistoryAction.MOVE_FRAME,
- self._on_order_move,
- detail=self._history_detail.move_frame,
- )
- self._sequencer_order_panel.on_set_order_entry = self._undoable(
- HistoryAction.SET_ORDER_ENTRY,
- self._sequencer_order_logic.set_order_entry,
- detail=self._history_detail.set_order_entry,
- )
- self._sequencer_order_panel.on_set_master_entry = self._undoable(
- HistoryAction.SET_ORDER_ENTRY,
- self._sequencer_order_logic.set_master_entry,
- detail=self._history_detail.set_master_entry,
- )
- self._sequencer_order_panel.on_cell_selected = self._on_order_cell_focused
-
- def _wire_block_callbacks(self) -> None:
- """Connects the grids' block gestures to the clipboard they copy into.
-
- A copy reads the project and leaves it as it stands, so it is wired straight through
- instead of through :meth:`_undoable`: a transaction over it would record an entry the
- history has nothing to restore for. The three gestures that do write are whole ones, each
- recording the single entry that takes the grid back to where it stood.
-
- Each grid also asks whether its own slot holds a block, which is what a menu offering
- Paste consults before it is opened.
- """
- self._sequencer_tracker_panel.can_paste_block = self._can_paste_tracker_block
- self._sequencer_order_panel.can_paste_block = self._can_paste_order_block
- self._sequencer_tracker_panel.on_copy_block = self._on_tracker_copy_block
- self._sequencer_tracker_panel.on_cut_block = self._undoable(
- HistoryAction.CUT_BLOCK,
- self._cut_tracker_block,
- detail=self._history_detail.tracker_block,
- )
- self._sequencer_tracker_panel.on_delete_block = self._undoable(
- HistoryAction.DELETE_BLOCK,
- self._tracker_block_writer.clear,
- detail=self._history_detail.tracker_block,
- )
- self._sequencer_tracker_panel.on_paste_block = self._undoable(
- HistoryAction.PASTE_BLOCK,
- self._paste_tracker_block,
- detail=self._history_detail.tracker_paste,
- )
- self._sequencer_order_panel.on_copy_block = self._on_order_copy_block
- self._sequencer_order_panel.on_cut_block = self._undoable(
- HistoryAction.CUT_BLOCK,
- self._cut_order_block,
- detail=self._history_detail.order_block,
- )
- self._sequencer_order_panel.on_delete_block = self._undoable(
- HistoryAction.DELETE_BLOCK,
- self._order_block_writer.clear,
- detail=self._history_detail.order_block,
- )
- self._sequencer_order_panel.on_paste_block = self._undoable(
- HistoryAction.PASTE_BLOCK,
- self._paste_order_block,
- detail=self._history_detail.order_paste,
- )
-
- def _can_paste_tracker_block(self) -> bool:
- """Whether the tracker has a block to write, which is what its Paste item is offered on."""
- return self._tracker_block_in_hand() is not None
-
- def _can_paste_order_block(self) -> bool:
- """Whether the order has a block to write, which is what its Paste item is offered on."""
- return self._order_block_in_hand() is not None
-
- def _tracker_block_in_hand(self) -> Optional[TrackerBlock]:
- """The block a tracker paste would write: the system clipboard's while its text is one.
-
- Text another instance copied reads as a block here, so it stands ahead of the slot the
- tracker copied into, and text from anywhere else leaves that slot's own block in hand.
- """
- parsed = self._tracker_text_cache.block(self._system_clipboard.read())
- if parsed is not None:
- return parsed
-
- return self._clipboard.tracker_block
-
- def _order_block_in_hand(self) -> Optional[OrderBlock]:
- """The block an order paste would write: the system clipboard's while its text is one.
-
- Text another instance copied reads as a block here, so it stands ahead of the slot the
- order copied into, and text from anywhere else leaves that slot's own block in hand.
- """
- parsed = self._order_text_cache.block(self._system_clipboard.read())
- if parsed is not None:
- return parsed
-
- return self._clipboard.order_block
-
- def _on_tracker_copy_block(self, region: TrackerRegion) -> None:
- """Puts the tracker's selected block on both clipboards, for a paste to replay.
-
- The slot keeps the block exactly, and the system clipboard keeps the text form of it, so
- the same copy reaches a paste here and a paste in another instance.
- """
- block = self._tracker_block_reader.read(region)
- self._clipboard.store_tracker_block(block)
- self._system_clipboard.write(self._tracker_block_text.state(block, region))
-
- def _cut_tracker_block(self, region: TrackerRegion) -> None:
- """Takes the block a region covers onto the clipboard, then empties what it covered."""
- self._on_tracker_copy_block(region)
- self._tracker_block_writer.clear(region)
-
- def _paste_tracker_block(self, cell: TrackerCell) -> None:
- """Writes the block the tracker has in hand at a cell, while a copy has been made."""
- block = self._tracker_block_in_hand()
- if block is not None:
- self._tracker_block_writer.write(block, cell)
-
- def _on_order_copy_block(self, region: OrderRegion) -> None:
- """Puts the order's selected block on both clipboards, for a paste to replay.
-
- The slot keeps the block exactly, and the system clipboard keeps the text form of it, so
- the same copy reaches a paste here and a paste in another instance.
- """
- block = self._order_block_reader.read(region)
- self._clipboard.store_order_block(block)
- self._system_clipboard.write(self._order_block_text.state(block, region))
-
- def _cut_order_block(self, region: OrderRegion) -> None:
- """Takes the block a region covers onto the clipboard, then silences what it covered."""
- self._on_order_copy_block(region)
- self._order_block_writer.clear(region)
-
- def _paste_order_block(self, cell: OrderCell) -> None:
- """Writes the block the order has in hand at a cell, while a copy has been made."""
- block = self._order_block_in_hand()
- if block is not None:
- self._order_block_writer.write(block, cell)
-
- def _wire_samples_callbacks(self) -> None:
- self._sequencer_samples_logic.on_samples_changed = self._on_samples_changed
- self._sequencer_samples_logic.on_edit_sample_requested = self._dispatch_edit_sample
- self._sequencer_samples_logic.on_autoplay_error = self._on_preview_error
- self._sequencer_samples_panel.sample_footprint = self._sequencer_samples_logic.build_sample_footprint
- self._sequencer_samples_panel.on_sample_selected = self._on_sample_selected
- self._sequencer_samples_panel.on_sample_edit_requested = self._sequencer_samples_logic.request_edit
- self._sequencer_samples_panel.on_loop_changed = self._undoable(
- HistoryAction.SET_SAMPLE_LOOP,
- self._sequencer_samples_logic.set_sample_loop,
- detail=self._history_detail.set_sample_loop,
- )
- self._sequencer_samples_panel.on_remove_requested = self._remove_sample
- self._sequencer_samples_panel.on_play_requested = self._sequencer_samples_logic.play_sample
- self._sequencer_samples_panel.on_move_requested = self._undoable(
- HistoryAction.MOVE_SAMPLE,
- self._sequencer_samples_logic.move_sample,
- detail=self._history_detail.move_sample,
- )
- self._sequencer_samples_panel.on_rename_committed = self._submit_rename
- self._sequencer_samples_panel.on_duplicate_requested = self._undoable(
- HistoryAction.DUPLICATE_SAMPLE,
- self._sequencer_samples_logic.duplicate_sample,
- detail=self._history_detail.duplicate_sample,
- )
-
- def _wire_browser_callbacks(self) -> None:
- self._sequencer_browser_panel.set_collapse_handler(self._on_browser_collapse_changed)
- self._sequencer_browser_panel.on_favorites_filter_changed = self._on_browser_favorites_filter_changed
- self._sequencer_browser_panel.on_add_to_sequencer = self.import_reconstruction
- self._sequencer_browser_panel.can_add_to_sequencer = self._is_project_open
- self._sequencer_browser_panel.on_replace_in_sequencer = self.replace_reconstruction
- self._sequencer_browser_panel.replace_in_sequencer_label = self._replace_target_label
- self._sequencer_browser_panel.on_locate_original_audio = self._original_audio_locator.locate
- self._sequencer_browser_panel.on_refresh_tree = self._sequencer_browser_logic.refresh_tree
- self._sequencer_tree_logic.on_lock_state_changed = self._sequencer_browser_panel.set_tree_enabled
- self._sequencer_tree_logic.on_favorite_changed = self._on_favorite_changed
- self._sequencer_tree_logic.on_search_update_needed = self._sequencer_browser_panel.update_tree_visibility
- self._sequencer_tree_logic.on_autoplay_error = self._on_preview_error
-
- def _wire_playback_callbacks(self) -> None:
- self._song_player_logic.on_position_changed = self._on_player_position_changed
- self._song_player_logic.on_view_changed = self._on_player_view_changed
- self._song_player_logic.on_error = self._on_player_error
-
- def _wire_project_callbacks(self) -> None:
- self._project_controller.on_settings_changed = self._sequencer_tracker_logic.push_settings
- self._project_controller.on_song_changed = self._on_song_changed
- self._project_controller.on_samples_changed = self._sequencer_samples_logic.push_samples
- self._project_controller.on_project_replaced = self._on_project_replaced
-
- def _on_card_collapse_changed(self, card_tag: str, collapsed: bool) -> None:
- """Persists a card's collapsed state so it restores on the next launch."""
- self._session_manager.set_card_collapsed(card_tag, collapsed)
- if card_tag == TAG_SEQUENCER_HISTORY_PANEL:
- self._sync_samples_height()
-
- def _on_browser_collapse_changed(self, card_tag: str, collapsed: bool) -> None:
- """Persists the browser panel's collapse, then docks or restores the width of the column it fills."""
- self._session_manager.set_card_collapsed(card_tag, collapsed)
- self._sync_browser_width()
-
- def _on_browser_favorites_filter_changed(self, panel_tag: str, favorites_only: bool) -> None:
- """Persists the browser's favorites filter so it opens in the same mode on the next launch."""
- self._session_manager.set_favorites_filter_active(panel_tag, favorites_only)
-
- def sync_responsive_layout(self) -> None:
- """Refits this tab's side column to the current viewport, the entry the resize handler calls."""
- self._sync_browser_width()
-
- def _sync_browser_width(self) -> None:
- """Shrinks the browser column to the collapse rail when collapsed, else sizes it to the viewport width."""
- if self._sequencer_browser_panel.collapsed:
- width = self._geometry.rail_width
- else:
- width = expanded_side_width(
- self._geometry.side_width,
- dpg.get_viewport_client_width(),
- self._geometry.baseline_viewport_width,
- self._side_panel_count,
- self._geometry.center_weight,
- )
-
- dpg_configure_item(_LEFT_COLUMN_TAG, width=width)
-
- def _stacked_card_gap(self) -> int:
- """The rendered vertical gap between two cards stacked in the right column.
-
- The cards are separated by a ``panel_gap`` spacer, but DearPyGui also lays its ``ItemSpacing.y``
- on each side of that spacer, so the real gap is the spacer plus two of those spacings. The
- spacing is read from the base theme, which sets it explicitly, so the gap tracks the theme's
- value.
- """
- spacing = ThemeRegistry.get(TAG_GLOBAL_THEME_DEFAULT).get_style(
- dpg.mvAll,
- dpg.mvStyleVar_ItemSpacing,
- )
- spacing_y = int(spacing[1]) if spacing is not None else 0
- return self._geometry.panel_gap + 2 * spacing_y
-
- def _sync_samples_height(self) -> None:
- """Reserves the bottom space the history card and its inter-card gap occupy, so samples fills the rest.
-
- The samples card fills the right column above the history card by reserving that footprint below
- it. History carries its own height in both states — filling the reservation while expanded, pinned
- to its header bar while collapsed — so this only has to size the reservation: the expanded history
- height, or the collapsed bar footprint. The reservation clears the full inter-card gap (see
- :meth:`_stacked_card_gap`) so the collapsed bar lands flush at the column bottom.
- """
- if self._sequencer_history_panel.collapsed:
- footprint = self._history_collapsed_footprint
- else:
- footprint = self._history_expanded_height
-
- self._sequencer_samples_panel.set_expanded_height(-(self._inter_card_gap + footprint))
-
- def _wire_history(self) -> None:
- self._sequencer_history_panel.on_undo = self.undo
- self._sequencer_history_panel.on_redo = self.redo
- self._sequencer_history_panel.on_jump_to = self.jump_to_history
-
- def _undoable(
- self,
- action: HistoryAction,
- callback: Callable[_UndoableParams, None],
- *,
- detail: Optional[Callable[_UndoableParams, HistoryDetail]] = None,
- coalesce: Optional[Callable[_UndoableParams, CoalesceKey]] = None,
- ) -> Callable[_UndoableParams, None]:
- """Wraps a state-changing hook so its whole gesture becomes one undo entry.
-
- Every mutation the wrapped callback triggers is grouped under ``action``;
- a gesture that changes nothing records no entry. ``detail`` computes the
- entry's coloured description segments from the same arguments the hook
- receives, and ``coalesce`` computes the gesture's target key from them:
- consecutive gestures sharing the same action and target collapse into a
- single entry.
-
- The gesture is batched inside its transaction, so however many rows it
- writes, the panels rebuild once — and they rebuild before the entry that
- undoes them is recorded, because the snapshot reads the project rather
- than the views.
- """
-
- def wrapped(
- *args: _UndoableParams.args,
- **kwargs: _UndoableParams.kwargs,
- ) -> None:
- description = detail(*args, **kwargs) if detail is not None else ()
- key = coalesce(*args, **kwargs) if coalesce is not None else None
- with (
- self._history.transaction(
- action,
- detail=description,
- coalesce=key,
- ),
- self._project_controller.batch(),
- ):
- callback(*args, **kwargs)
-
- return wrapped
-
- def _cell_key(
- self,
- row_index: int,
- generator: Optional[GeneratorName],
- ) -> CoalesceKey:
- """Identifies one cell of the displayed frame as a coalescing target.
-
- The sample column (``generator`` absent) is its own target, distinct from
- every channel column.
- """
- channel = generator if generator is not None else ""
- return (self._sequencer_tracker_logic.frame_index, channel, row_index)
-
- def _adjustment_key(
- self,
- region: TrackerRegion,
- _delta: int,
- ) -> CoalesceKey:
- """Identifies the cells an adjustment covers as one coalescing target.
-
- A streak of nudges over the same block reads as one entry, so holding a transpose key steps
- the selection and leaves a single step to undo; moving the cursor or reaching the selection
- out starts the next one.
- """
- return (
- self._sequencer_tracker_logic.frame_index,
- region.first_row,
- region.last_row,
- region.first_slot,
- region.last_slot,
- )
-
- def _edit_row_key(
- self,
- row_index: int,
- generator: Optional[GeneratorName],
- sample_id: Optional[str],
- transpose: Optional[int],
- volume: Optional[int],
- ) -> CoalesceKey:
- """Extends the cell target with the subcolumns the edit writes.
-
- Consecutive edits of one cell coalesce only when they write the same
- subcolumns, so entering a note and then tweaking its volume stay
- separate entries.
- """
- return (
- *self._cell_key(row_index, generator),
- sample_id is not None,
- transpose is not None,
- volume is not None,
- )
-
- def _module_setting_key(self, _value: int) -> CoalesceKey:
- """Marks a module-wide setting as one target, shared by its whole streak."""
- return ()
-
- def _on_settings_changed(
- self,
- view_model: SequencerSettingsViewModel,
- ) -> None:
- """Hands the project's song settings to the two panels that read them.
-
- The module panel shows the timing fields themselves; the tracker reads the metre out of
- the same view model, so a highlight edited in the project properties retints the grid as
- soon as the dialog commits.
- """
- self._sequencer_module_panel.update_settings(view_model)
- self._sequencer_tracker_panel.update_settings(view_model)
-
- def _on_project_replaced(self) -> None:
- """Realigns the tab with a replaced project, keeping the mute set across history navigation.
-
- Undo, redo, and history jumps replace the project as well, and the history manager reports
- itself restoring throughout, so the channels the user is listening through carry across
- them. A new, opened, or closed document begins a fresh listening session instead, with
- every channel audible.
- """
- if not self._history.is_restoring:
- self._sequencer_channels_logic.reset()
-
- self._history.reset()
- self.refresh()
-
- def play_from_current_frame(self) -> None:
- """Plays from the frame the tracker is showing, seeking in place when already playing."""
- self._on_order_play_from(self._sequencer_tracker_logic.frame_index)
-
- def undo(self) -> None:
- self._history.undo()
-
- def redo(self) -> None:
- self._history.redo()
-
- def jump_to_history(self, index: int) -> None:
- self._history.jump_to(index)
-
- def refresh_history(self) -> None:
- """Re-renders the history panel from the manager's current stack.
-
- Called by the application's history fan-out, which owns the manager's
- single ``on_history_changed`` slot and forwards each change here and to
- the menu bar.
- """
- self._sequencer_history_panel.update_view(self._build_history_view_model())
-
- def reconstruction_edit_detail(
- self,
- sample_id: str,
- generator_name: GeneratorName,
- feature_key: FeatureKey,
- ) -> HistoryDetail:
- """Describes a reconstruction edit for the project history's detail line."""
- return self._history_detail.edit_reconstruction(
- sample_id,
- generator_name,
- feature_key,
- )
-
- def _build_history_view_model(self) -> HistoryViewModel:
- cursor = self._history.cursor
- entries = tuple(
- HistoryEntryViewModel(
- index=index,
- label=self._history_action_label(entry.action),
- detail_segments=tuple(self._resolve_detail_segment(segment) for segment in entry.detail),
- is_current=index == cursor,
- is_future=index > cursor,
- )
- for index, entry in enumerate(self._history.entries)
- )
- return HistoryViewModel(entries=entries, cursor=cursor)
-
- def _history_action_label(self, action: HistoryAction) -> str:
- return self._language_manager[
- Page.SEQUENCER,
- Panel.HISTORY,
- TextType.LABEL,
- action,
- ]
-
- def _resolve_detail_segment(
- self,
- segment: Union[HistoryDetailSegment, HistoryDetailWordSegment],
- ) -> HistoryDetailSegment:
- if isinstance(segment, HistoryDetailWordSegment):
- text = self._language_manager[
- Page.SEQUENCER,
- Panel.HISTORY,
- TextType.LABEL,
- SequencerHistoryElements(segment.word.value),
- ]
- return HistoryDetailSegment(text=text, role=segment.role)
-
- return segment
-
- def initialize(self) -> None:
- """Pushes the current project into every sequencer panel.
-
- Called once after the GUI is built so the panels reflect the project the
- application started with (or restored).
- """
- self._song_player_logic.refresh_view()
- self.refresh()
-
- def refresh(self) -> None:
- self._nes_frequency_change_acknowledged = False
- self._song_player_logic.stop()
- self._sequencer_tracker_logic.refresh()
- self._sequencer_order_logic.refresh()
- self._sequencer_samples_logic.push_samples()
- self._sequencer_channels_logic.push_channels()
- is_open = self._project_controller.is_open
- self._sequencer_module_panel.set_enabled(is_open)
- self._sequencer_tracker_panel.set_enabled(is_open)
- self._sequencer_order_panel.set_enabled(is_open)
- self._sequencer_history_panel.set_enabled(is_open)
-
- def repaint(self) -> None:
- """Draws every table again so its tints take the palette now in place.
-
- DearPyGui keeps a table's row, column and cell tints as state of the table rather than
- as a property of an item, so they take a new colour by being issued again. Each panel
- answers for the tints it owns, and this is where the palette asks all three.
- """
- self._sequencer_tracker_panel.repaint()
- self._sequencer_order_panel.repaint()
- self._sequencer_samples_panel.repaint()
-
- def refresh_browser(self) -> None:
- self._sequencer_browser_panel.refresh()
-
- def save_browser_shape(self) -> None:
- """Writes down the rows the browser stands open, so a later run brings them back."""
- self._session_manager.set_expanded_rows(
- self._sequencer_browser_panel.tag,
- self._sequencer_browser_panel.expanded_rows,
- )
-
- def repaint_browser_favorites(self, nodes: Sequence[FileSystemNode]) -> None:
- self._sequencer_browser_panel.update_favorite_indicators(nodes)
-
- def _on_song_changed(self) -> None:
- self._sequencer_tracker_logic.push_settings()
- self._sequencer_tracker_logic.push_tracker()
- self._sequencer_order_logic.push_order()
-
- def _on_player_error(self, error: Exception) -> None:
- self._dialogs.show_error(error)
-
- def _on_player_view_changed(self, view_model: SongPlayerViewModel) -> None:
- """Settles the marks the transport owns, and how far the grid chases the playhead.
-
- The player emits a view on every position update and on every change to the setting, so
- reading the follow behaviour here keeps the grid in step both while a song sounds and the
- moment the reader picks another mode.
- """
- self._sequencer_tracker_panel.set_row_following(view_model.follow_mode.follows_row)
- if not view_model.is_playing and not view_model.is_paused:
- self._playing_position = None
- self._mark_playhead()
-
- def _on_player_position_changed(
- self,
- order_position: int,
- row_index: int,
- ) -> None:
- """Moves the marks the playhead carries, showing the frame it sounds when following.
-
- The frame is selected ahead of the marks so the row's mark, and the scroll that reveals it,
- land on the pattern the playhead has reached.
- """
- self._playing_position = SongPosition(
- order_position=order_position,
- row_index=row_index,
- )
- if self._song_player_logic.follow_mode.follows_pattern:
- self._sequencer_tracker_logic.select_frame(order_position)
-
- self._mark_playhead()
-
- def _mark_playhead(self) -> None:
- """Puts the playhead's marks where it stands, on both grids.
-
- The order grid marks the frame the playhead sounds; the tracker takes the whole position,
- since the row it marks belongs to the pattern of that frame.
- """
- position = self._playing_position
- self._sequencer_tracker_panel.set_playing_position(position)
- self._sequencer_order_panel.set_playing_position(
- position.order_position if position is not None else None,
- )
-
- def _on_order_frame_selected(self, frame_index: int) -> None:
- """Selects an order frame in the tracker, and moves the playhead too when following.
-
- While the view follows the playhead, choosing another order during playback relocates the
- playhead to it (the seek no-ops when stopped); otherwise the selection only changes which
- pattern is edited, leaving playback where it is.
- """
- self._sequencer_tracker_logic.select_frame(frame_index)
- if self._song_player_logic.follow_mode.follows_pattern:
- self._song_player_logic.seek(frame_index)
-
- def _on_preview_error(self, exception: Exception) -> None:
- FrameCallbackManager.set_frame_callback(lambda: self._dialogs.show_error(exception))
-
- def _is_project_open(self) -> bool:
- return self._project_controller.is_open
-
- def import_reconstruction(self, filepath: Path) -> None:
- if not self._project_controller.is_open:
- self._dialogs.show_info(
- TAG_GLOBAL_DIALOG_NO_PROJECT_OPEN,
- self._msg_no_project,
- self._ttl_no_project,
- )
- return
-
- try:
- reconstruction = self._sequencer_browser_logic.load_reconstruction(filepath)
- except (SampleToNESError, OSError) as exception:
- logger.error_with_traceback(
- exception,
- f"Failed to load reconstruction from {filepath}",
- )
- self._dialogs.show_error(exception)
- return
-
- self._add_reconstruction_with_frequency_check(reconstruction, filepath.stem)
-
- def import_reconstruction_object(self, reconstruction: Reconstruction, name: str) -> None:
- """Adds an in-memory reconstruction — the one open in the Reconstruction tab — as a sample.
-
- The sample embeds an independent copy, so the open document keeps its own source-audio
- location and file backing while the project stores a self-contained, detached sample.
- """
- if not self._project_controller.is_open:
- self._dialogs.show_info(
- TAG_GLOBAL_DIALOG_NO_PROJECT_OPEN,
- self._msg_no_project,
- self._ttl_no_project,
- )
- return
-
- self._add_reconstruction_with_frequency_check(
- reconstruction.model_copy(deep=True),
- name,
- )
-
- def _add_reconstruction_with_frequency_check(
- self,
- reconstruction: Reconstruction,
- name: str,
- ) -> None:
- """Adds a loaded reconstruction once its NES frequency is settled against the project's.
-
- An empty project adopts the incoming frequency, since the rate times nothing there yet; a
- project that already holds samples confirms first, because the rate governs how all of them
- play back.
- """
- self._reconcile_nes_frequency(
- reconstruction,
- lambda adopt_frequency: self._commit_add_reconstruction(
- reconstruction,
- name,
- adopt_frequency=adopt_frequency,
- ),
- can_adopt_frequency=not self._project_controller.has_samples,
- )
-
- def _reconcile_nes_frequency(
- self,
- reconstruction: Reconstruction,
- commit: Callable[[Optional[int]], None],
- *,
- can_adopt_frequency: bool,
- ) -> None:
- """Settles a reconstruction's NES frequency against the project's, then commits the gesture.
-
- A reconstruction renders at the frequency it was generated at, so bringing one recorded at
- another rate into the project plays it back wrong. Equal rates commit straight away. A
- mismatch the project can absorb — because the rate times nothing that outlives the gesture —
- adopts the incoming rate. Otherwise the user decides, seeing both rates, and confirming
- keeps the project's rate for the samples already timed by it.
-
- Args:
- reconstruction: The reconstruction being brought into the project.
- commit: Performs the gesture, receiving the frequency to adopt, or ``None`` to keep the
- project's.
- can_adopt_frequency: Whether adopting the incoming rate re-times only what this gesture
- itself brings in, which settles a mismatch without asking.
- """
- reconstruction_frequency = reconstruction.config.nes_frequency
- project_frequency = self._sequencer_tracker_logic.settings.nes_frequency
-
- if reconstruction_frequency == project_frequency:
- commit(None)
- return
-
- if can_adopt_frequency:
- commit(reconstruction_frequency)
- return
-
- self._dialogs.show_confirmation(
- tag=TAG_SEQUENCER_BROWSER_DIALOG_FREQUENCY,
- title=self._language_manager["global.dialog.title.frequency_mismatch"],
- message=self._language_manager["global.dialog.message.frequency_mismatch"].format(
- reconstruction=reconstruction_frequency,
- project=project_frequency,
- ),
- on_confirm=lambda: commit(None),
- ok_label=self._language_manager["global.dialog.label.add_anyway"],
- )
-
- def _commit_add_reconstruction(
- self,
- reconstruction: Reconstruction,
- name: str,
- *,
- adopt_frequency: Optional[int],
- ) -> None:
- """Adds a reconstruction as one undoable gesture, optionally adopting its frequency.
-
- The frequency reconciliation and the sample insertion form a single history
- entry, so undoing a freshly-imported sample also restores the prior rate.
- """
- with self._history.transaction(
- HistoryAction.ADD_SAMPLE,
- detail=self._history_detail.add_sample(name),
- ):
- if adopt_frequency is not None:
- self._sequencer_tracker_logic.set_nes_frequency(adopt_frequency)
- self._sequencer_browser_logic.add_reconstruction(reconstruction, name)
-
- self._on_tab_switch(Tab.SEQUENCER)
-
- def replace_reconstruction(self, filepath: Path) -> None:
- """Substitutes the selected sample's reconstruction with a browser file's.
-
- The sample keeps its id and position, so every pattern row referencing it sounds the
- incoming audio while the tracker shows it where it was, and it takes the file's name the way
- an import does. The target is whatever the samples panel has selected as the gesture starts,
- which is also what named the menu item the user clicked.
- """
- selection = self._sequencer_samples_panel.selection
- if selection is None:
- return
-
- try:
- reconstruction = self._sequencer_browser_logic.load_reconstruction(filepath)
- except (SampleToNESError, OSError) as exception:
- logger.error_with_traceback(exception, f"Failed to load reconstruction from {filepath}")
- self._dialogs.show_error(exception)
- return
-
- self._reconcile_nes_frequency(
- reconstruction,
- lambda adopt_frequency: self._commit_replace_reconstruction(
- selection.sample_id,
- reconstruction,
- filepath.stem,
- adopt_frequency=adopt_frequency,
- ),
- can_adopt_frequency=self._project_controller.sample_count == 1,
- )
-
- def _commit_replace_reconstruction(
- self,
- sample_id: str,
- reconstruction: Reconstruction,
- name: str,
- *,
- adopt_frequency: Optional[int],
- ) -> None:
- """Substitutes a sample's reconstruction as one undoable gesture, renaming it to the source.
-
- The detail is composed while the sample still holds the outgoing reconstruction, so it reads
- the name being replaced alongside the incoming one. The replacement is announced in the same
- window, ahead of the substitution, because an editor holding the sample open recognises it by
- the identity of the reconstruction it is about to give up. The frequency adoption, the rename,
- and the substitution share a single history entry, so one undo restores the previous rate,
- name, and audio together.
- """
- detail = self._history_detail.replace_sample(sample_id, name)
- with self._history.transaction(
- HistoryAction.REPLACE_SAMPLE,
- detail=detail,
- ):
- if adopt_frequency is not None:
- self._sequencer_tracker_logic.set_nes_frequency(adopt_frequency)
-
- self._sequencer_samples_logic.rename_sample(sample_id, name)
- self._on_sample_reconstruction_replaced(sample_id, reconstruction)
- self._sequencer_browser_logic.replace_reconstruction(sample_id, reconstruction)
-
- def _replace_target_label(self) -> Optional[str]:
- """The indexed label of the sample a browser replacement would overwrite, while one is selected."""
- selection = self._sequencer_samples_panel.selection
- if selection is None:
- return None
-
- return selection.label
-
- def _dispatch_edit_sample(self, sample_id: str) -> None:
- self._on_edit_sample_requested(sample_id)
-
- def _on_tracker_play_from_row(self, row_index: int) -> None:
- """Starts playback from the right-clicked row of the frame the tracker is showing."""
- self._song_player_logic.play_from(
- self._sequencer_tracker_logic.frame_index,
- row_index,
- )
-
- def _on_samples_changed(
- self,
- view_model: SequencerSamplesViewModel,
- ) -> None:
- self._sequencer_samples_panel.update_view(view_model)
- self._sequencer_tracker_panel.update_samples(view_model)
-
- def _on_sample_selected(self, sample_id: str) -> None:
- self._sequencer_tracker_panel.deselect_cell()
- self._sequencer_order_panel.deselect_cell()
- self._sequencer_samples_logic.request_autoplay(sample_id)
- logger.debug(f"Sequencer sample selected: {sample_id}")
-
- def _remove_sample(self, sample_id: str) -> None:
- """Removes a sample, confirming first only when a pattern still references it.
-
- An unused sample is dropped silently; a referenced one would clear every row
- that points at it, so the user confirms that loss first.
- """
- if not self._sequencer_samples_logic.is_sample_used(sample_id):
- self._perform_remove_sample(sample_id)
- return
-
- name = self._sequencer_samples_logic.sample_name(sample_id)
- self._dialogs.show_confirmation(
- tag=TAG_SEQUENCER_INSTRUMENTS_DIALOG_REMOVE,
- title=self._language_manager["global.dialog.title.remove_sample"],
- message=self._language_manager["global.dialog.message.remove_sample"].format(name=name),
- on_confirm=lambda: self._perform_remove_sample(sample_id),
- ok_label=self._language_manager["global.dialog.label.remove"],
- )
-
- def _perform_remove_sample(self, sample_id: str) -> None:
- detail = self._history_detail.remove_sample(sample_id)
- with self._history.transaction(
- HistoryAction.REMOVE_SAMPLE,
- detail=detail,
- ):
- self._sequencer_samples_logic.remove_sample(sample_id)
-
- def _submit_rename(self, sample_id: str, name: str) -> None:
- """Applies an inline rename, ignoring a blank name so the sample keeps its current one."""
- stripped = name.strip()
- if stripped:
- detail = self._history_detail.rename_sample(
- self._sequencer_samples_logic.sample_name(sample_id),
- stripped,
- )
- with self._history.transaction(
- HistoryAction.RENAME_SAMPLE,
- detail=detail,
- ):
- self._sequencer_samples_logic.rename_sample(sample_id, stripped)
-
- def _request_nes_frequency_change(self, nes_frequency: int) -> None:
- """Applies a NES-frequency change, confirming first when it would re-time existing samples.
-
- The rate governs how every sample plays back, so changing it on a project that already
- holds samples prompts once (until acknowledged for the session); an empty or acknowledged
- project applies silently. Cancelling restores the field to the project's current value.
- """
- if nes_frequency == self._sequencer_tracker_logic.settings.nes_frequency:
- return
-
- if self._nes_frequency_change_acknowledged or not self._project_controller.has_samples:
- self._perform_nes_frequency_change(nes_frequency)
- return
-
- self._dialogs.show_confirmation(
- tag=TAG_SEQUENCER_MODULE_DIALOG_NES_FREQUENCY,
- title=self._language_manager["global.dialog.title.change_nes_frequency"],
- message=self._language_manager["global.dialog.message.change_nes_frequency"],
- on_confirm=lambda: self._perform_nes_frequency_change(nes_frequency),
- ok_label=self._language_manager["global.dialog.label.change_and_retune"],
- opt_out_label=self._language_manager["global.dialog.label.dont_ask_again"],
- on_opt_out=self._acknowledge_nes_frequency_changes,
- on_cancel=self._sequencer_tracker_logic.push_settings,
- )
-
- def _perform_nes_frequency_change(self, nes_frequency: int) -> None:
- """Applies the rate as one undo entry, then requests a retune of the now-stale samples.
-
- The entry carries a rate-keyed coalesce target so the asynchronous per-sample retune
- results fold back into this same ``SET_NES_FREQUENCY`` entry: one undo restores both the
- prior rate and the prior reconstructions, and a later change to a different rate appends
- a fresh entry.
- """
- with self._history.transaction(
- HistoryAction.SET_NES_FREQUENCY,
- detail=self._history_detail.value(nes_frequency),
- coalesce=(nes_frequency,),
- ):
- self._sequencer_tracker_logic.set_nes_frequency(nes_frequency)
-
- self._on_nes_frequency_changed(nes_frequency)
-
- def nes_frequency_detail(self, nes_frequency: int) -> HistoryDetail:
- """The history detail for a NES-frequency change, so the retune can reuse its undo entry."""
- return self._history_detail.value(nes_frequency)
-
- def _acknowledge_nes_frequency_changes(self) -> None:
- self._nes_frequency_change_acknowledged = True
-
- def _on_order_remove(self, position: int) -> None:
- length_before = self._project_controller.order_length
- self._sequencer_order_logic.remove_from_order(position)
- self._relocate_playhead(
- lambda playhead: remap_after_remove(
- playhead,
- position,
- length_before - 1,
- )
- )
-
- def _on_order_duplicate(self, position: int) -> None:
- self._sequencer_order_logic.duplicate_frame(position)
- self._settle_inserted_frame(position + 1)
-
- def _on_order_clone(self, position: int) -> None:
- self._sequencer_order_logic.clone_frame(position)
- self._settle_inserted_frame(position + 1)
-
- def _settle_inserted_frame(self, position: int) -> None:
- """Carries the playhead and the shown frame over a frame that has just been inserted.
-
- A frame arriving at ``position`` pushes every later frame one along, so a playhead
- standing on one of them follows it, and the grid moves to the new frame for the reader
- to work on.
- """
- self._relocate_playhead(
- lambda playhead: remap_after_insert(
- playhead,
- position,
- )
- )
- self._select_frame_when_idle(position)
-
- def _on_order_insert(self, position: int) -> None:
- self._sequencer_order_logic.insert_frame(position + 1)
- self._relocate_playhead(
- lambda playhead: remap_after_insert(
- playhead,
- position + 1,
- )
- )
- self._select_frame_when_idle(position + 1)
-
- def _on_order_clear(self, position: int) -> None:
- """Clears every channel in the frame; no index shift, so the playhead is left in place.
-
- A sounding voice keeps ringing across the now-empty frame (only an explicit note-off cuts it).
- """
- self._sequencer_order_logic.clear_frame(position)
-
- def _on_order_move(self, from_position: int, to_position: int) -> None:
- self._sequencer_order_logic.move_frame(from_position, to_position)
- self._relocate_playhead(
- lambda playhead: remap_after_move(
- playhead,
- from_position,
- to_position,
- )
- )
- self._sequencer_tracker_logic.select_frame(to_position)
-
- def _on_order_play_from(self, position: int) -> None:
- """Plays from a frame: relocates the playhead when already playing, else starts there."""
- if self._song_player_logic.is_playing():
- self._song_player_logic.seek(position)
- else:
- self._song_player_logic.play_from(position)
-
- def _relocate_playhead(self, remap: Callable[[int], int]) -> None:
- """Keeps the live playhead on the frame it was sounding after a structural order edit.
-
- Both grids take the new position straight away, ahead of the worker's next row update, so
- rapid edits (e.g. a held Alt+arrow) stay in step, and a paused playhead — which reports no
- further rows — is marked on the frame the edit moved it to.
- """
- if self._playing_position is None:
- return
-
- order_position = remap(self._playing_position.order_position)
- if order_position == self._playing_position.order_position:
- return
-
- self._playing_position = SongPosition(
- order_position=order_position,
- row_index=self._playing_position.row_index,
- )
- self._song_player_logic.relocate(order_position)
- self._mark_playhead()
-
- def _select_frame_when_idle(self, frame_index: int) -> None:
- """Moves the editor selection to a frame, unless playback is actively driving it."""
- if not self._song_player_logic.is_playing():
- self._sequencer_tracker_logic.select_frame(frame_index)
-
- def _on_tracker_cell_focused(self) -> None:
- """Drops the order cursor and sample selection when the tracker tracker takes focus.
-
- The tracker, order, and samples panels each register a key-router scope active only while
- it holds a selection; keeping a single selection across the three lets only the focused
- panel consume keystrokes.
- """
- self._sequencer_order_panel.deselect_cell()
- self._sequencer_samples_panel.deselect()
-
- def _on_order_cell_focused(self) -> None:
- """Drops the tracker cursor and sample selection when the order tracker takes focus."""
- self._sequencer_tracker_panel.deselect_cell()
- self._sequencer_samples_panel.deselect()
-
- def create_tab(self) -> None:
- with dpg.tab(
- tag=TAG_GLOBAL_TAB_SEQUENCER,
- parent=TAG_GLOBAL_TABS,
- label=self._language_manager["global.menu.label.tab_sequencer"],
- ):
- self._side_panel_count = TabColumns.build(
- panel_gap=self._geometry.panel_gap,
- columns=[
- ColumnSpec(
- tag=_LEFT_COLUMN_TAG,
- build=self._sequencer_browser_panel.create_panel,
- theme=TAG_GLOBAL_THEME_PANEL_SURFACE,
- width=self._geometry.side_width,
- height=self._geometry.side_height,
- no_scrollbar=True,
- ),
- ColumnSpec(
- tag=_CENTER_COLUMN_TAG,
- build=self._build_center_column,
- theme=TAG_GLOBAL_THEME_PANEL_GROUND,
- border=False,
- ),
- ColumnSpec(
- tag=_RIGHT_COLUMN_TAG,
- build=self._build_right_column,
- theme=TAG_GLOBAL_THEME_PANEL_GROUND,
- width=self._instruments_width,
- height=self._right_height,
- border=False,
- no_scrollbar=True,
- ),
- ],
- )
-
- self._sync_browser_width()
-
- def _build_center_column(self, parent: str) -> None:
- """Stacks the order table and tracker tracker down the centre column."""
- self._sequencer_order_panel.create_panel(parent)
- dpg.add_spacer(height=self._geometry.panel_gap, parent=parent)
- self._sequencer_tracker_panel.create_panel(parent)
-
- def _build_right_column(self, parent: str) -> None:
- """Stacks the module settings, samples, and history cards in the right column."""
- self._sequencer_module_panel.create_panel(parent)
- dpg.add_spacer(height=self._geometry.panel_gap)
- self._sequencer_samples_panel.create_panel(parent)
- dpg.add_spacer(height=self._geometry.panel_gap)
- self._sequencer_history_panel.create_panel(parent)
- self._sync_samples_height()
-
- @property
- def player(self) -> AudioPlayerProtocol:
- return self._guarded_player
-
- @property
- def edit_surfaces(self) -> Tuple[EditSurfaceProtocol, ...]:
- """The panels offering editing gestures on what they hold selected.
-
- The three hold one selection between them — a cursor in either grid, a row in the samples
- list — so the menu bar reaches whichever one has it.
- """
- return (
- self._sequencer_tracker_panel.edit_surface,
- self._sequencer_order_panel.edit_surface,
- self._sequencer_samples_panel,
- )
diff --git a/src/sampletones_core/trackers/__init__.py b/src/sampletones_application/coordinators/tabs/sequencer/__init__.py
similarity index 100%
rename from src/sampletones_core/trackers/__init__.py
rename to src/sampletones_application/coordinators/tabs/sequencer/__init__.py
diff --git a/src/sampletones_application/coordinators/tabs/sequencer/blocks.py b/src/sampletones_application/coordinators/tabs/sequencer/blocks.py
new file mode 100644
index 000000000..a7157fc4e
--- /dev/null
+++ b/src/sampletones_application/coordinators/tabs/sequencer/blocks.py
@@ -0,0 +1,144 @@
+from typing import Optional
+
+from sampletones_application.logic.project.controller import ProjectController
+from sampletones_application.logic.sequencer.clipboard import (
+ OrderBlockText,
+ ParsedBlockCache,
+ ProjectSampleDirectory,
+ SequencerClipboard,
+ TrackerBlockText,
+)
+from sampletones_application.logic.sequencer.order import (
+ OrderBlock,
+ OrderBlockReader,
+ OrderBlockWriter,
+ SequencerOrderLogic,
+)
+from sampletones_application.logic.sequencer.tracker import (
+ SequencerTrackerLogic,
+ TrackerBlock,
+ TrackerBlockReader,
+ TrackerBlockWriter,
+)
+from sampletones_application.utils.gui.clipboard import TextClipboard
+from sampletones_application.view_model.sequencer.region import (
+ OrderCell,
+ OrderRegion,
+ TrackerCell,
+ TrackerRegion,
+)
+
+
+class SequencerBlocks:
+ """The blocks both grids copy, cut and paste, and the two clipboards they travel on.
+
+ A copy lands in this tab's own slot and, as text, on the system clipboard, so the same block
+ reaches a paste here and a paste in another instance. A paste reads the system clipboard first
+ while what stands there is a block, which is what lets one instance hand a block to the next.
+
+ Every gesture here reads and writes the project as it stands; recording what a gesture undoes
+ is the caller's, so the coordinator wraps the writing ones in a history transaction.
+ """
+
+ def __init__(
+ self,
+ tracker_logic: SequencerTrackerLogic,
+ order_logic: SequencerOrderLogic,
+ project_controller: ProjectController,
+ *,
+ text_clipboard: TextClipboard,
+ ) -> None:
+ self._clipboard: SequencerClipboard = SequencerClipboard()
+ self._text_clipboard: TextClipboard = text_clipboard
+ self._tracker_text: TrackerBlockText = TrackerBlockText(
+ samples=ProjectSampleDirectory(project_controller),
+ )
+ self._order_text: OrderBlockText = OrderBlockText()
+ self._tracker_cache: ParsedBlockCache[TrackerBlock] = ParsedBlockCache(self._tracker_text.parse)
+ self._order_cache: ParsedBlockCache[OrderBlock] = ParsedBlockCache(self._order_text.parse)
+ self._tracker_reader: TrackerBlockReader = TrackerBlockReader(tracker_logic)
+ self._tracker_writer: TrackerBlockWriter = TrackerBlockWriter(tracker_logic)
+ self._order_reader: OrderBlockReader = OrderBlockReader(order_logic)
+ self._order_writer: OrderBlockWriter = OrderBlockWriter(order_logic)
+
+ def can_paste_tracker(self) -> bool:
+ """Whether the tracker has a block to write, which is what its Paste item is offered on."""
+ return self.tracker_in_hand() is not None
+
+ def can_paste_order(self) -> bool:
+ """Whether the order has a block to write, which is what its Paste item is offered on."""
+ return self.order_in_hand() is not None
+
+ def tracker_in_hand(self) -> Optional[TrackerBlock]:
+ """The block a tracker paste would write: the system clipboard's while its text is one.
+
+ Text another instance copied reads as a block here, so it stands ahead of the slot the
+ tracker copied into, and text from anywhere else leaves that slot's own block in hand.
+ """
+ parsed = self._tracker_cache.block(self._text_clipboard.read())
+ if parsed is not None:
+ return parsed
+
+ return self._clipboard.tracker_block
+
+ def order_in_hand(self) -> Optional[OrderBlock]:
+ """The block an order paste would write: the system clipboard's while its text is one.
+
+ Text another instance copied reads as a block here, so it stands ahead of the slot the
+ order copied into, and text from anywhere else leaves that slot's own block in hand.
+ """
+ parsed = self._order_cache.block(self._text_clipboard.read())
+ if parsed is not None:
+ return parsed
+
+ return self._clipboard.order_block
+
+ def copy_tracker(self, region: TrackerRegion) -> None:
+ """Puts the tracker's selected block on both clipboards, for a paste to replay.
+
+ The slot keeps the block exactly, and the system clipboard keeps the text form of it, so
+ the same copy reaches a paste here and a paste in another instance.
+ """
+ block = self._tracker_reader.read(region)
+ self._clipboard.store_tracker_block(block)
+ self._text_clipboard.write(self._tracker_text.state(block, region))
+
+ def cut_tracker(self, region: TrackerRegion) -> None:
+ """Takes the block a region covers onto the clipboard, then empties what it covered."""
+ self.copy_tracker(region)
+ self._tracker_writer.clear(region)
+
+ def clear_tracker(self, region: TrackerRegion) -> None:
+ """Empties every cell a tracker region covers, leaving the clipboards as they stand."""
+ self._tracker_writer.clear(region)
+
+ def paste_tracker(self, cell: TrackerCell) -> None:
+ """Writes the block the tracker has in hand at a cell, while a copy has been made."""
+ block = self.tracker_in_hand()
+ if block is not None:
+ self._tracker_writer.write(block, cell)
+
+ def copy_order(self, region: OrderRegion) -> None:
+ """Puts the order's selected block on both clipboards, for a paste to replay.
+
+ The slot keeps the block exactly, and the system clipboard keeps the text form of it, so
+ the same copy reaches a paste here and a paste in another instance.
+ """
+ block = self._order_reader.read(region)
+ self._clipboard.store_order_block(block)
+ self._text_clipboard.write(self._order_text.state(block, region))
+
+ def cut_order(self, region: OrderRegion) -> None:
+ """Takes the block a region covers onto the clipboard, then silences what it covered."""
+ self.copy_order(region)
+ self._order_writer.clear(region)
+
+ def clear_order(self, region: OrderRegion) -> None:
+ """Silences every cell an order region covers, leaving the clipboards as they stand."""
+ self._order_writer.clear(region)
+
+ def paste_order(self, cell: OrderCell) -> None:
+ """Writes the block the order has in hand at a cell, while a copy has been made."""
+ block = self.order_in_hand()
+ if block is not None:
+ self._order_writer.write(block, cell)
diff --git a/src/sampletones_application/coordinators/tabs/sequencer/coordinator.py b/src/sampletones_application/coordinators/tabs/sequencer/coordinator.py
new file mode 100644
index 000000000..686f46146
--- /dev/null
+++ b/src/sampletones_application/coordinators/tabs/sequencer/coordinator.py
@@ -0,0 +1,909 @@
+from pathlib import Path
+from typing import Callable, Sequence, Tuple
+
+from sampletones_application.categories.hierarchy import Tab
+from sampletones_application.categories.instrument import InstrumentImportMessages
+from sampletones_application.categories.manager import LanguageManager
+from sampletones_application.config.managers.config import ConfigManager
+from sampletones_application.config.managers.session import SessionManager
+from sampletones_application.constants.playback import FollowMode
+from sampletones_application.coordinators.edit.protocol import EditSurfaceProtocol
+from sampletones_application.coordinators.export import InstrumentExportCoordinator
+from sampletones_application.coordinators.original_audio import OriginalAudioLocator
+from sampletones_application.coordinators.playback.guard import GuardedPlayer
+from sampletones_application.coordinators.playback.protocol import AudioPlayerProtocol
+from sampletones_application.coordinators.tabs.sequencer.blocks import SequencerBlocks
+from sampletones_application.coordinators.tabs.sequencer.frames import SequencerFrames
+from sampletones_application.coordinators.tabs.sequencer.history import SequencerHistoryRecorder
+from sampletones_application.coordinators.tabs.sequencer.layout import SequencerTabLayout
+from sampletones_application.coordinators.tabs.sequencer.playhead import SequencerPlayhead
+from sampletones_application.coordinators.tabs.sequencer.project import OpenProjectRequirement
+from sampletones_application.coordinators.tabs.sequencer.reconstructions import SequencerReconstructions
+from sampletones_application.coordinators.tabs.sequencer.voices import SequencerVoices
+from sampletones_application.logic.history.action import HistoryAction
+from sampletones_application.logic.history.manager import HistoryManager
+from sampletones_application.logic.project.controller import ProjectController
+from sampletones_application.logic.reconstruction.browser.manager import BrowserManager
+from sampletones_application.logic.sequencer.browser import SequencerBrowserLogic
+from sampletones_application.logic.sequencer.channels import SequencerChannelsLogic
+from sampletones_application.logic.sequencer.history_detail import (
+ SequencerHistoryDetail,
+)
+from sampletones_application.logic.sequencer.order import (
+ SequencerOrderLogic,
+)
+from sampletones_application.logic.sequencer.playback.song_player import SongPlayerLogic
+from sampletones_application.logic.sequencer.playback.synthesizer import RowSynthesizer
+from sampletones_application.logic.sequencer.tracker import (
+ SequencerTrackerLogic,
+ TrackerRegionAdjuster,
+)
+from sampletones_application.logic.sequencer.voices import SequencerVoicesLogic
+from sampletones_application.logic.shared.tree import TreeLogic
+from sampletones_application.parameters.sequencer import SequencerTabParameters
+from sampletones_application.services.song_player.service import SongPlayerService
+from sampletones_application.tags.sequencer import (
+ TAG_SEQUENCER_BROWSER_PANEL,
+ TAG_SEQUENCER_HISTORY_PANEL,
+ TAG_SEQUENCER_MODULE_DIALOG_NES_FREQUENCY,
+ TAG_SEQUENCER_MODULE_PANEL,
+ TAG_SEQUENCER_ORDER_WINDOW_ORDER_CARD,
+ TAG_SEQUENCER_TRACKER_PANEL,
+ TAG_SEQUENCER_VOICES_PANEL,
+)
+from sampletones_application.ui.elements.status import GUIStatusBar
+from sampletones_application.ui.panels.sequencer.browser import GUISequencerBrowserPanel
+from sampletones_application.ui.panels.sequencer.history import GUISequencerHistoryPanel
+from sampletones_application.ui.panels.sequencer.module import GUISequencerModulePanel
+from sampletones_application.ui.panels.sequencer.order.panel import GUISequencerOrderPanel
+from sampletones_application.ui.panels.sequencer.tracker.panel import GUISequencerTrackerPanel
+from sampletones_application.ui.panels.sequencer.voices.panel import (
+ GUISequencerVoicesPanel,
+)
+from sampletones_application.utils.gui.clipboard import SystemTextClipboard
+from sampletones_application.utils.gui.dialogs import DialogsRenderer
+from sampletones_application.utils.gui.frame import FrameCallbackManager
+from sampletones_application.utils.gui.keyboard import ActivePredicate, KeyRouter
+from sampletones_application.utils.gui.shortcuts.source import ShortcutSource
+from sampletones_application.view_model.sequencer.channels import (
+ SequencerChannelsViewModel,
+)
+from sampletones_application.view_model.sequencer.settings import (
+ SequencerSettingsViewModel,
+)
+from sampletones_application.view_model.sequencer.song_player import SongPlayerViewModel
+from sampletones_application.view_model.sequencer.voices import (
+ SequencerVoicesViewModel,
+)
+from sampletones_application.view_model.shared.history import (
+ HistoryDetail,
+)
+from sampletones_core.audio import AudioDeviceManager
+from sampletones_core.constants.enums import ChannelName, FeatureKey
+from sampletones_core.reconstructions import Reconstruction
+from sampletones_core.structures.tree import FileSystemNode
+from sampletones_shared.logger import logger
+from sampletones_shared.types.callback import StringCallback, VoidCallback
+
+
+class SequencerTabCoordinator:
+ def __init__(
+ self,
+ config_manager: ConfigManager,
+ session_manager: SessionManager,
+ audio_device_manager: AudioDeviceManager,
+ key_router: KeyRouter,
+ shortcut_source: ShortcutSource,
+ browser_manager: BrowserManager,
+ project_controller: ProjectController,
+ history: HistoryManager,
+ original_audio_locator: OriginalAudioLocator,
+ instrument_exports: InstrumentExportCoordinator,
+ *,
+ tab_active: ActivePredicate,
+ layout: SequencerTabParameters,
+ language_manager: LanguageManager,
+ dialogs: DialogsRenderer,
+ status_bar: GUIStatusBar,
+ on_edit_voice_requested: StringCallback,
+ on_favorite_changed: Callable[[FileSystemNode], None],
+ on_sample_reconstruction_replaced: Callable[[str, Reconstruction], None],
+ on_tab_switch: Callable[[Tab], None],
+ on_nes_frequency_changed: Callable[[int], None],
+ on_channels_changed: VoidCallback,
+ ) -> None:
+ self._project_controller = project_controller
+ self._session_manager = session_manager
+ self._history = history
+ self._original_audio_locator = original_audio_locator
+ self._instrument_exports = instrument_exports
+ self._on_edit_voice_requested = on_edit_voice_requested
+ self._on_favorite_changed = on_favorite_changed
+ self._on_sample_reconstruction_replaced = on_sample_reconstruction_replaced
+ self._on_tab_switch = on_tab_switch
+ self._on_nes_frequency_changed = on_nes_frequency_changed
+ self._on_channels_changed = on_channels_changed
+ self._language_manager = language_manager
+ self._dialogs = dialogs
+
+ self._nes_frequency_change_acknowledged: bool = False
+
+ self._sequencer_browser_logic: SequencerBrowserLogic = SequencerBrowserLogic(
+ config_manager,
+ browser_manager,
+ project_controller,
+ )
+ self._sequencer_tree_logic: TreeLogic = TreeLogic(
+ session_manager,
+ audio_device_manager,
+ scheduling=layout.scheduling,
+ )
+ self._sequencer_browser_panel: GUISequencerBrowserPanel = GUISequencerBrowserPanel(
+ self._sequencer_browser_logic.tree,
+ self._sequencer_tree_logic,
+ scheduling=layout.scheduling,
+ language_manager=language_manager,
+ status_bar=status_bar,
+ colors=layout.tree_colors,
+ initial_collapsed=session_manager.is_card_collapsed(TAG_SEQUENCER_BROWSER_PANEL),
+ initial_favorites_only=session_manager.is_favorites_filter_active(TAG_SEQUENCER_BROWSER_PANEL),
+ initial_expanded_rows=session_manager.expanded_rows(TAG_SEQUENCER_BROWSER_PANEL),
+ )
+ self._sequencer_tracker_logic: SequencerTrackerLogic = SequencerTrackerLogic(project_controller)
+ self._sequencer_order_logic: SequencerOrderLogic = SequencerOrderLogic(project_controller)
+ self._blocks: SequencerBlocks = SequencerBlocks(
+ self._sequencer_tracker_logic,
+ self._sequencer_order_logic,
+ project_controller,
+ text_clipboard=SystemTextClipboard(),
+ )
+ self._tracker_region_adjuster: TrackerRegionAdjuster = TrackerRegionAdjuster(self._sequencer_tracker_logic)
+ self._sequencer_voices_logic: SequencerVoicesLogic = SequencerVoicesLogic(
+ project_controller,
+ session_manager,
+ audio_device_manager,
+ scheduling=layout.scheduling,
+ )
+ self._sequencer_channels_logic: SequencerChannelsLogic = SequencerChannelsLogic()
+ self._song_player_logic: SongPlayerLogic = SongPlayerLogic(
+ audio_device_manager,
+ project_controller,
+ session_manager,
+ service=SongPlayerService(
+ audio_device_manager,
+ RowSynthesizer(
+ project_controller,
+ config_manager.config,
+ active_channels=lambda: self._sequencer_channels_logic.active_channels,
+ sample_rate=lambda: audio_device_manager.sample_rate,
+ ),
+ should_loop=lambda: session_manager.loop_song,
+ master_gain=lambda: session_manager.master_gain,
+ ),
+ )
+ self._guarded_player = GuardedPlayer(
+ self._song_player_logic,
+ dialogs=dialogs,
+ error_message=language_manager["global.player.message.audio_playback_error"],
+ )
+ self._sequencer_tracker_panel: GUISequencerTrackerPanel = GUISequencerTrackerPanel(
+ self._sequencer_tracker_logic.settings,
+ layout=layout.sequencer,
+ channel_colors=layout.channel_colors,
+ initial_collapsed=session_manager.is_card_collapsed(TAG_SEQUENCER_TRACKER_PANEL),
+ initial_octave=session_manager.octave,
+ language_manager=language_manager,
+ key_router=key_router,
+ tab_active=tab_active,
+ shortcut_source=shortcut_source,
+ )
+ self._sequencer_module_panel: GUISequencerModulePanel = GUISequencerModulePanel(
+ self._sequencer_tracker_logic.settings,
+ layout=layout.sequencer,
+ inputs=layout.inputs,
+ initial_collapsed=session_manager.is_card_collapsed(TAG_SEQUENCER_MODULE_PANEL),
+ language_manager=language_manager,
+ status_bar=status_bar,
+ )
+ self._sequencer_order_panel: GUISequencerOrderPanel = GUISequencerOrderPanel(
+ layout=layout.sequencer,
+ channel_colors=layout.channel_colors,
+ plus_minus_layout=layout.plus_minus,
+ initial_collapsed=session_manager.is_card_collapsed(TAG_SEQUENCER_ORDER_WINDOW_ORDER_CARD),
+ language_manager=language_manager,
+ key_router=key_router,
+ tab_active=tab_active,
+ shortcut_source=shortcut_source,
+ )
+ self._sequencer_voices_panel: GUISequencerVoicesPanel = GUISequencerVoicesPanel(
+ layout=layout.sequencer,
+ detail_color=layout.muted_color,
+ initial_collapsed=session_manager.is_card_collapsed(TAG_SEQUENCER_VOICES_PANEL),
+ language_manager=language_manager,
+ status_bar=status_bar,
+ key_router=key_router,
+ tab_active=tab_active,
+ shortcut_source=shortcut_source,
+ )
+ self._sequencer_history_panel: GUISequencerHistoryPanel = GUISequencerHistoryPanel(
+ layout=layout.sequencer,
+ feature_colors=layout.feature_colors,
+ initial_collapsed=session_manager.is_card_collapsed(TAG_SEQUENCER_HISTORY_PANEL),
+ language_manager=language_manager,
+ status_bar=status_bar,
+ )
+ self._layout: SequencerTabLayout = SequencerTabLayout(
+ self._sequencer_browser_panel,
+ self._sequencer_order_panel,
+ self._sequencer_tracker_panel,
+ self._sequencer_module_panel,
+ self._sequencer_voices_panel,
+ self._sequencer_history_panel,
+ layout=layout,
+ session_manager=session_manager,
+ language_manager=language_manager,
+ )
+ self._playhead: SequencerPlayhead = SequencerPlayhead(
+ self._sequencer_tracker_panel,
+ self._sequencer_order_panel,
+ )
+ self._frames: SequencerFrames = SequencerFrames(
+ self._sequencer_order_logic,
+ self._sequencer_tracker_logic,
+ self._song_player_logic,
+ project_controller,
+ self._playhead,
+ )
+ self._open_project: OpenProjectRequirement = OpenProjectRequirement(
+ project_controller,
+ dialogs=dialogs,
+ language_manager=language_manager,
+ )
+ self._history_detail: SequencerHistoryDetail = SequencerHistoryDetail(
+ self._sequencer_tracker_logic,
+ self._sequencer_voices_logic,
+ )
+ self._voices: SequencerVoices = SequencerVoices(
+ self._sequencer_voices_logic,
+ history,
+ self._history_detail,
+ project_controller,
+ session_manager,
+ self._open_project,
+ dialogs=dialogs,
+ language_manager=language_manager,
+ import_messages=InstrumentImportMessages.build(language_manager),
+ import_reconstruction=self.import_reconstruction,
+ )
+ self._reconstructions: SequencerReconstructions = SequencerReconstructions(
+ self._sequencer_browser_logic,
+ self._sequencer_tracker_logic,
+ self._sequencer_voices_logic,
+ self._sequencer_voices_panel,
+ history,
+ self._history_detail,
+ project_controller,
+ self._open_project,
+ dialogs=dialogs,
+ language_manager=language_manager,
+ on_tab_switch=on_tab_switch,
+ on_sample_reconstruction_replaced=on_sample_reconstruction_replaced,
+ )
+ self._recorder: SequencerHistoryRecorder = SequencerHistoryRecorder(
+ history,
+ project_controller,
+ self._sequencer_tracker_logic,
+ language_manager=language_manager,
+ )
+
+ self._wire_callbacks()
+
+ def _wire_callbacks(self) -> None:
+ """Connects every panel and logic object this tab owns to the handler that serves it."""
+ self._wire_collapse_handlers()
+ self._wire_module_callbacks()
+ self._wire_tracker_callbacks()
+ self._wire_channels_callbacks()
+ self._wire_order_callbacks()
+ self._wire_block_callbacks()
+ self._wire_voices_callbacks()
+ self._wire_browser_callbacks()
+ self._wire_playback_callbacks()
+ self._wire_project_callbacks()
+ self._wire_history()
+
+ def _wire_collapse_handlers(self) -> None:
+ for panel in (
+ self._sequencer_order_panel,
+ self._sequencer_tracker_panel,
+ self._sequencer_module_panel,
+ self._sequencer_voices_panel,
+ self._sequencer_history_panel,
+ ):
+ panel.set_collapse_handler(self._layout.on_card_collapse_changed)
+
+ def _wire_module_callbacks(self) -> None:
+ self._sequencer_module_panel.on_nes_frequency = self._request_nes_frequency_change
+ self._sequencer_module_panel.on_rows_per_pattern = self._recorder.undoable(
+ HistoryAction.SET_ROWS_PER_PATTERN,
+ self._sequencer_tracker_logic.set_rows_per_pattern,
+ detail=self._history_detail.value,
+ coalesce=self._recorder.module_setting_key,
+ )
+ self._sequencer_module_panel.on_tempo = self._recorder.undoable(
+ HistoryAction.SET_TEMPO,
+ self._sequencer_tracker_logic.set_tempo,
+ detail=self._history_detail.value,
+ coalesce=self._recorder.module_setting_key,
+ )
+ self._sequencer_module_panel.on_speed = self._recorder.undoable(
+ HistoryAction.SET_SPEED,
+ self._sequencer_tracker_logic.set_speed,
+ detail=self._history_detail.value,
+ coalesce=self._recorder.module_setting_key,
+ )
+
+ def _wire_tracker_callbacks(self) -> None:
+ self._sequencer_tracker_panel.on_clear_row = self._recorder.undoable(
+ HistoryAction.CLEAR_ROW,
+ self._sequencer_tracker_logic.clear_cell,
+ detail=self._history_detail.clear_row,
+ )
+ self._sequencer_tracker_panel.on_clear_subcolumn = self._recorder.undoable(
+ HistoryAction.CLEAR_SUBCOLUMN,
+ self._sequencer_tracker_logic.clear_cell_subcolumn,
+ detail=self._history_detail.clear_subcolumn,
+ )
+ self._sequencer_tracker_panel.on_set_row = self._recorder.undoable(
+ HistoryAction.EDIT_ROW,
+ self._sequencer_tracker_logic.write_cell,
+ detail=self._history_detail.edit_row,
+ coalesce=self._recorder.edit_row_key,
+ )
+ self._sequencer_tracker_panel.on_set_note_off = self._recorder.undoable(
+ HistoryAction.NOTE_OFF,
+ self._sequencer_tracker_logic.cut_note,
+ detail=self._history_detail.note_off,
+ coalesce=self._recorder.cell_key,
+ )
+ self._sequencer_tracker_panel.on_note_typed = self._recorder.undoable(
+ HistoryAction.EDIT_ROW,
+ self._sequencer_tracker_logic.write_note,
+ detail=self._history_detail.note_typed,
+ coalesce=self._recorder.note_key,
+ )
+ self._sequencer_tracker_panel.on_octave_changed = self._session_manager.set_octave
+ self._sequencer_tracker_panel.on_cell_selected = self._on_tracker_cell_focused
+ self._sequencer_tracker_panel.on_play_from_row = self._on_tracker_play_from_row
+ self._sequencer_tracker_panel.on_play_from_frame = self.play_from_current_frame
+ self._sequencer_tracker_panel.on_adjust_transpose = self._recorder.undoable(
+ HistoryAction.ADJUST_TRANSPOSE,
+ self._tracker_region_adjuster.adjust_transpose,
+ detail=self._history_detail.adjust_transpose,
+ coalesce=self._recorder.adjustment_key,
+ )
+ self._sequencer_tracker_panel.on_adjust_volume = self._recorder.undoable(
+ HistoryAction.ADJUST_VOLUME,
+ self._tracker_region_adjuster.adjust_volume,
+ detail=self._history_detail.adjust_volume,
+ coalesce=self._recorder.adjustment_key,
+ )
+ self._sequencer_tracker_logic.on_settings_changed = self._on_settings_changed
+ self._sequencer_tracker_logic.on_tracker_changed = self._sequencer_tracker_panel.update_tracker
+ self._sequencer_tracker_logic.on_frame_changed = self._sequencer_order_panel.select_position
+
+ def _wire_channels_callbacks(self) -> None:
+ """Connects the tracker's column headers and the order table's row labels to the mute set
+ the song player reads.
+
+ Both tables name the same channels and switch the same set, so each panel's hooks reach the
+ channels logic directly and both show every change. Muting is a monitoring gesture, so these
+ hooks record no history entry.
+ """
+ self._sequencer_channels_logic.on_channels_changed = self._show_channels
+ for panel in (self._sequencer_tracker_panel, self._sequencer_order_panel):
+ panel.on_channel_mute_toggled = self._sequencer_channels_logic.toggle
+ panel.on_channel_soloed = self._sequencer_channels_logic.solo
+ panel.on_channels_toggled = self._sequencer_channels_logic.toggle_all
+ panel.on_channels_muted = self._sequencer_channels_logic.mute_all
+ panel.on_channels_unmuted = self._sequencer_channels_logic.unmute_all
+
+ def _show_channels(self, view_model: SequencerChannelsViewModel) -> None:
+ """Shows the mute set in both tables and in the menu bar, so a channel reads the same
+ wherever it appears.
+
+ The menu bar sits above this tab and rebuilds its own state, so it is handed the change
+ as a signal and reads the mute set back through :attr:`channels`.
+ """
+ self._sequencer_tracker_panel.update_channels(view_model)
+ self._sequencer_order_panel.update_channels(view_model)
+ self._on_channels_changed()
+
+ @property
+ def channels(self) -> SequencerChannelsViewModel:
+ """The mute set the tables show, for the menu bar that lists the same channels."""
+ return self._sequencer_channels_logic.build_channels()
+
+ def toggle_channel(self, channel: ChannelName) -> None:
+ """Flips one channel between audible and silent, the menu's per-channel gesture."""
+ self._sequencer_channels_logic.toggle(channel)
+
+ def unmute_all_channels(self) -> None:
+ """Returns every channel to audible, the menu's whole-mix gesture."""
+ self._sequencer_channels_logic.unmute_all()
+
+ def set_follow_mode(self, mode: FollowMode) -> None:
+ """Chooses how far the view chases the playhead, the menu's and keyboard's gesture.
+
+ The player holds the setting and emits a view as it changes, which is what settles the
+ grid's following and the menu's mark together.
+ """
+ self._song_player_logic.set_follow_mode(mode)
+
+ def _wire_order_callbacks(self) -> None:
+ self._sequencer_order_logic.on_order_changed = self._sequencer_order_panel.update_order
+ self._sequencer_order_panel.on_frame_selected = self._frames.select
+ self._sequencer_order_panel.on_remove_requested = self._recorder.undoable(
+ HistoryAction.REMOVE_FRAME,
+ self._frames.remove,
+ detail=self._history_detail.remove_frame,
+ )
+ self._sequencer_order_panel.on_duplicate_requested = self._recorder.undoable(
+ HistoryAction.DUPLICATE_FRAME,
+ self._frames.duplicate,
+ detail=self._history_detail.copy_frame,
+ )
+ self._sequencer_order_panel.on_clone_requested = self._recorder.undoable(
+ HistoryAction.CLONE_FRAME,
+ self._frames.clone,
+ detail=self._history_detail.copy_frame,
+ )
+ self._sequencer_order_panel.on_insert_requested = self._recorder.undoable(
+ HistoryAction.ADD_FRAME,
+ self._frames.insert,
+ detail=self._history_detail.add_frame,
+ )
+ self._sequencer_order_panel.on_clear_requested = self._recorder.undoable(
+ HistoryAction.CLEAR_FRAME,
+ self._frames.clear,
+ detail=self._history_detail.clear_frame,
+ )
+ self._sequencer_order_panel.on_play_from_requested = self._frames.play_from
+ self._sequencer_order_panel.on_move_requested = self._recorder.undoable(
+ HistoryAction.MOVE_FRAME,
+ self._frames.move,
+ detail=self._history_detail.move_frame,
+ )
+ self._sequencer_order_panel.on_set_order_entry = self._recorder.undoable(
+ HistoryAction.SET_ORDER_ENTRY,
+ self._sequencer_order_logic.set_order_entry,
+ detail=self._history_detail.set_order_entry,
+ )
+ self._sequencer_order_panel.on_set_master_entry = self._recorder.undoable(
+ HistoryAction.SET_ORDER_ENTRY,
+ self._sequencer_order_logic.set_master_entry,
+ detail=self._history_detail.set_master_entry,
+ )
+ self._sequencer_order_panel.on_cell_selected = self._on_order_cell_focused
+
+ def _wire_block_callbacks(self) -> None:
+ """Connects the grids' block gestures to the clipboard they copy into.
+
+ A copy reads the project and leaves it as it stands, so it is wired straight through
+ instead of through :meth:`_undoable`: a transaction over it would record an entry the
+ history has nothing to restore for. The three gestures that do write are whole ones, each
+ recording the single entry that takes the grid back to where it stood.
+
+ Each grid also asks whether its own slot holds a block, which is what a menu offering
+ Paste consults before it is opened.
+ """
+ self._sequencer_tracker_panel.can_paste_block = self._blocks.can_paste_tracker
+ self._sequencer_order_panel.can_paste_block = self._blocks.can_paste_order
+ self._sequencer_tracker_panel.on_copy_block = self._blocks.copy_tracker
+ self._sequencer_tracker_panel.on_cut_block = self._recorder.undoable(
+ HistoryAction.CUT_BLOCK,
+ self._blocks.cut_tracker,
+ detail=self._history_detail.tracker_block,
+ )
+ self._sequencer_tracker_panel.on_delete_block = self._recorder.undoable(
+ HistoryAction.DELETE_BLOCK,
+ self._blocks.clear_tracker,
+ detail=self._history_detail.tracker_block,
+ )
+ self._sequencer_tracker_panel.on_paste_block = self._recorder.undoable(
+ HistoryAction.PASTE_BLOCK,
+ self._blocks.paste_tracker,
+ detail=self._history_detail.tracker_paste,
+ )
+ self._sequencer_order_panel.on_copy_block = self._blocks.copy_order
+ self._sequencer_order_panel.on_cut_block = self._recorder.undoable(
+ HistoryAction.CUT_BLOCK,
+ self._blocks.cut_order,
+ detail=self._history_detail.order_block,
+ )
+ self._sequencer_order_panel.on_delete_block = self._recorder.undoable(
+ HistoryAction.DELETE_BLOCK,
+ self._blocks.clear_order,
+ detail=self._history_detail.order_block,
+ )
+ self._sequencer_order_panel.on_paste_block = self._recorder.undoable(
+ HistoryAction.PASTE_BLOCK,
+ self._blocks.paste_order,
+ detail=self._history_detail.order_paste,
+ )
+
+ def _wire_voices_callbacks(self) -> None:
+ self._sequencer_voices_logic.on_voices_changed = self._on_voices_changed
+ self._sequencer_voices_logic.on_edit_voice_requested = self._dispatch_edit_voice
+ self._sequencer_voices_logic.on_autoplay_error = self._on_preview_error
+ self._sequencer_voices_panel.voice_footprint = self._sequencer_voices_logic.build_voice_footprint
+ self._sequencer_voices_panel.on_voice_selected = self._on_voice_selected
+ self._sequencer_voices_panel.on_voice_edit_requested = self._sequencer_voices_logic.request_edit
+ self._sequencer_voices_panel.on_remove_requested = self._voices.remove
+ self._sequencer_voices_panel.on_play_requested = self._sequencer_voices_logic.play_voice
+ self._sequencer_voices_panel.on_move_requested = self._recorder.undoable(
+ HistoryAction.MOVE_VOICE,
+ self._sequencer_voices_logic.move_voice,
+ detail=self._history_detail.move_voice,
+ )
+ self._sequencer_voices_panel.on_rename_committed = self._voices.submit_rename
+ self._sequencer_voices_panel.on_duplicate_requested = self._recorder.undoable(
+ HistoryAction.DUPLICATE_VOICE,
+ self._sequencer_voices_logic.duplicate_voice,
+ detail=self._history_detail.duplicate_voice,
+ )
+ self._sequencer_voices_panel.on_new_instrument_requested = self.add_instrument
+ self._sequencer_voices_panel.on_add_sample_requested = self.add_sample_from_file
+ self._sequencer_voices_panel.on_import_instrument_requested = self.import_instrument
+ self._sequencer_voices_panel.voice_instruments = self._instrument_exports.voice_instruments
+ self._sequencer_voices_panel.on_export_instrument_requested = self._instrument_exports.request_voice
+ self._sequencer_voices_panel.instrument_channels = self._sequencer_voices_logic.instrument_channels
+ self._sequencer_voices_panel.on_instrument_from_channel_requested = self.add_instrument_from_channel
+
+ def _wire_browser_callbacks(self) -> None:
+ self._sequencer_browser_panel.set_collapse_handler(self._layout.on_browser_collapse_changed)
+ self._sequencer_browser_panel.on_favorites_filter_changed = self._layout.on_browser_favorites_filter_changed
+ self._sequencer_browser_panel.on_add_to_sequencer = self.import_reconstruction
+ self._sequencer_browser_panel.can_add_to_sequencer = self._is_project_open
+ self._sequencer_browser_panel.on_replace_in_sequencer = self.replace_reconstruction
+ self._sequencer_browser_panel.replace_in_sequencer_label = self._reconstructions.replace_target_label
+ self._sequencer_browser_panel.on_locate_original_audio = self._original_audio_locator.locate
+ self._sequencer_browser_panel.on_refresh_tree = self._sequencer_browser_logic.refresh_tree
+ self._sequencer_tree_logic.on_lock_state_changed = self._sequencer_browser_panel.set_tree_enabled
+ self._sequencer_tree_logic.on_favorite_changed = self._on_favorite_changed
+ self._sequencer_tree_logic.on_search_update_needed = self._sequencer_browser_panel.update_tree_visibility
+ self._sequencer_tree_logic.on_autoplay_error = self._on_preview_error
+
+ def _wire_playback_callbacks(self) -> None:
+ self._song_player_logic.on_position_changed = self._on_player_position_changed
+ self._song_player_logic.on_view_changed = self._on_player_view_changed
+ self._song_player_logic.on_error = self._on_player_error
+
+ def _wire_project_callbacks(self) -> None:
+ self._project_controller.on_settings_changed = self._sequencer_tracker_logic.push_settings
+ self._project_controller.on_song_changed = self._on_song_changed
+ self._project_controller.on_voices_changed = self._sequencer_voices_logic.push_voices
+ self._project_controller.on_project_replaced = self._on_project_replaced
+
+ def _wire_history(self) -> None:
+ self._sequencer_history_panel.on_undo = self.undo
+ self._sequencer_history_panel.on_redo = self.redo
+ self._sequencer_history_panel.on_jump_to = self.jump_to_history
+
+ def _on_settings_changed(
+ self,
+ view_model: SequencerSettingsViewModel,
+ ) -> None:
+ """Hands the project's song settings to the two panels that read them.
+
+ The module panel shows the timing fields themselves; the tracker reads the meter out of
+ the same view model, so a highlight edited in the project properties retints the grid as
+ soon as the dialog commits.
+ """
+ self._sequencer_module_panel.update_settings(view_model)
+ self._sequencer_tracker_panel.update_settings(view_model)
+
+ def _on_project_replaced(self) -> None:
+ """Realigns the tab with a replaced project, keeping the mute set across history navigation.
+
+ Undo, redo, and history jumps replace the project as well, and the history manager reports
+ itself restoring throughout, so the channels the user is listening through carry across
+ them. A new, opened, or closed document begins a fresh listening session instead, with
+ every channel audible.
+ """
+ if not self._history.is_restoring:
+ self._sequencer_channels_logic.reset()
+
+ self._history.reset()
+ self.refresh()
+
+ def play_from_current_frame(self) -> None:
+ """Plays from the frame the tracker is showing, seeking in place when already playing."""
+ self._frames.play_from(self._sequencer_tracker_logic.frame_index)
+
+ def add_instrument(self) -> None:
+ """Appends a hand-written voice to the pool, the menu bar's entry to the gesture."""
+ self._voices.add_instrument()
+
+ def add_instrument_from_channel(
+ self,
+ voice_id: str,
+ channel_name: ChannelName,
+ ) -> None:
+ """Takes what one channel of a voice plays as an instrument of its own, then opens it.
+
+ Args:
+ voice_id: The voice the channel belongs to.
+ channel_name: The channel whose envelopes the instrument takes.
+ """
+ self._voices.add_instrument_from_channel(voice_id, channel_name)
+
+ def add_sample_from_file(self) -> None:
+ """Brings a reconstruction saved anywhere on disk into the pool as a sample."""
+ self._voices.add_sample_from_file()
+
+ def import_instrument(self) -> None:
+ """Brings a FamiTracker instrument file into the pool as an instrument voice."""
+ self._voices.import_instrument()
+
+ def undo(self) -> None:
+ self._history.undo()
+
+ def redo(self) -> None:
+ self._history.redo()
+
+ def jump_to_history(self, index: int) -> None:
+ self._history.jump_to(index)
+
+ def refresh_history(self) -> None:
+ """Re-renders the history panel from the manager's current stack.
+
+ Called by the application's history fan-out, which owns the manager's
+ single ``on_history_changed`` slot and forwards each change here and to
+ the menu bar.
+ """
+ self._sequencer_history_panel.update_view(self._recorder.view_model())
+
+ def reconstruction_edit_detail(
+ self,
+ voice_id: str,
+ channel_name: ChannelName,
+ feature_key: FeatureKey,
+ ) -> HistoryDetail:
+ """Describes a reconstruction edit for the project history's detail line."""
+ return self._history_detail.edit_reconstruction(
+ voice_id,
+ channel_name,
+ feature_key,
+ )
+
+ def instrument_edit_detail(
+ self,
+ voice_id: str,
+ feature_key: FeatureKey,
+ ) -> HistoryDetail:
+ """Describes a hand-written voice's edited dimension for the project history."""
+ return self._history_detail.edit_instrument(voice_id, feature_key)
+
+ def reconstruction_stem_detail(
+ self,
+ voice_id: str,
+ stem_name: str,
+ ) -> HistoryDetail:
+ """Describes a recording taken out of a reconstruction for the project history."""
+ return self._history_detail.remove_stem(voice_id, stem_name)
+
+ def initialize(self) -> None:
+ """Pushes the current project into every sequencer panel.
+
+ Called once after the GUI is built so the panels reflect the project the
+ application started with (or restored).
+ """
+ self._song_player_logic.refresh_view()
+ self.refresh()
+
+ def refresh(self) -> None:
+ self._nes_frequency_change_acknowledged = False
+ self._song_player_logic.stop()
+ self._sequencer_tracker_logic.refresh()
+ self._sequencer_order_logic.refresh()
+ self._sequencer_voices_logic.push_voices()
+ self._sequencer_channels_logic.push_channels()
+ is_open = self._project_controller.is_open
+ self._sequencer_module_panel.set_enabled(is_open)
+ self._sequencer_tracker_panel.set_enabled(is_open)
+ self._sequencer_order_panel.set_enabled(is_open)
+ self._sequencer_history_panel.set_enabled(is_open)
+
+ def repaint(self) -> None:
+ """Draws every table again so its tints take the palette now in place.
+
+ DearPyGui keeps a table's row, column and cell tints as state of the table rather than
+ as a property of an item, so they take a new color by being issued again. Each panel
+ answers for the tints it owns, and this is where the palette asks all three.
+ """
+ self._sequencer_tracker_panel.repaint()
+ self._sequencer_order_panel.repaint()
+ self._sequencer_voices_panel.repaint()
+
+ def refresh_browser(self) -> None:
+ self._sequencer_browser_panel.refresh()
+
+ def save_browser_shape(self) -> None:
+ """Writes down the rows the browser stands open, so a later run brings them back."""
+ self._session_manager.set_expanded_rows(
+ self._sequencer_browser_panel.tag,
+ self._sequencer_browser_panel.expanded_rows,
+ )
+
+ def repaint_browser_favorites(self, nodes: Sequence[FileSystemNode]) -> None:
+ self._sequencer_browser_panel.update_favorite_indicators(nodes)
+
+ def _on_song_changed(self) -> None:
+ self._sequencer_tracker_logic.push_settings()
+ self._sequencer_tracker_logic.push_tracker()
+ self._sequencer_order_logic.push_order()
+
+ def _on_player_error(self, error: Exception) -> None:
+ self._dialogs.show_error(error)
+
+ def _on_player_view_changed(self, view_model: SongPlayerViewModel) -> None:
+ """Settles the marks the transport owns, and how far the grid chases the playhead.
+
+ The player emits a view on every position update and on every change to the setting, so
+ reading the follow behavior here keeps the grid in step both while a song sounds and the
+ moment the reader picks another mode.
+ """
+ self._sequencer_tracker_panel.set_row_following(view_model.follow_mode.follows_row)
+ if not view_model.is_playing and not view_model.is_paused:
+ self._playhead.stop()
+
+ def _on_player_position_changed(
+ self,
+ order_position: int,
+ row_index: int,
+ ) -> None:
+ """Moves the marks the playhead carries, showing the frame it sounds when following.
+
+ The frame is selected ahead of the marks so the row's mark, and the scroll that reveals it,
+ land on the pattern the playhead has reached.
+ """
+ self._playhead.stand_at(order_position, row_index)
+ if self._song_player_logic.follow_mode.follows_pattern:
+ self._sequencer_tracker_logic.select_frame(order_position)
+
+ self._playhead.mark()
+
+ def _on_preview_error(self, exception: Exception) -> None:
+ FrameCallbackManager.set_frame_callback(lambda: self._dialogs.show_error(exception))
+
+ def _is_project_open(self) -> bool:
+ return self._project_controller.is_open
+
+ def import_reconstruction(self, filepath: Path) -> None:
+ """Brings a reconstruction file into the pool as a sample."""
+ self._reconstructions.import_from_file(filepath)
+
+ def import_reconstruction_object(self, reconstruction: Reconstruction, name: str) -> None:
+ """Adds an in-memory reconstruction — the one open in the Reconstruction tab — as a sample."""
+ self._reconstructions.import_object(reconstruction, name)
+
+ def replace_reconstruction(self, filepath: Path) -> None:
+ """Substitutes the selected sample's reconstruction with a browser file's."""
+ self._reconstructions.replace_from_file(filepath)
+
+ def _dispatch_edit_voice(self, voice_id: str) -> None:
+ self._on_edit_voice_requested(voice_id)
+
+ def _on_tracker_play_from_row(self, row_index: int) -> None:
+ """Starts playback from the right-clicked row of the frame the tracker is showing."""
+ self._song_player_logic.play_from(
+ self._sequencer_tracker_logic.frame_index,
+ row_index,
+ )
+
+ def _on_voices_changed(
+ self,
+ view_model: SequencerVoicesViewModel,
+ ) -> None:
+ self._sequencer_voices_panel.update_view(view_model)
+ self._sequencer_tracker_panel.update_samples(view_model)
+
+ def _on_voice_selected(self, voice_id: str) -> None:
+ self._sequencer_tracker_panel.deselect_cell()
+ self._sequencer_order_panel.deselect_cell()
+ self._sequencer_voices_logic.request_autoplay(voice_id)
+ logger.debug(f"Sequencer sample selected: {voice_id}")
+
+ def _request_nes_frequency_change(self, nes_frequency: int) -> None:
+ """Applies a NES-frequency change, confirming first when it would re-time existing samples.
+
+ The rate governs how every sample plays back, so changing it on a project that already
+ holds samples prompts once (until acknowledged for the session); an empty or acknowledged
+ project applies silently. Cancelling restores the field to the project's current value.
+ """
+ if nes_frequency == self._sequencer_tracker_logic.settings.nes_frequency:
+ return
+
+ if self._nes_frequency_change_acknowledged or not self._project_controller.has_voices:
+ self._perform_nes_frequency_change(nes_frequency)
+ return
+
+ self._dialogs.show_confirmation(
+ tag=TAG_SEQUENCER_MODULE_DIALOG_NES_FREQUENCY,
+ title=self._language_manager["global.dialog.title.change_nes_frequency"],
+ message=self._language_manager["global.dialog.message.change_nes_frequency"],
+ on_confirm=lambda: self._perform_nes_frequency_change(nes_frequency),
+ ok_label=self._language_manager["global.dialog.label.change_and_retune"],
+ opt_out_label=self._language_manager["global.dialog.label.dont_ask_again"],
+ on_opt_out=self._acknowledge_nes_frequency_changes,
+ on_cancel=self._sequencer_tracker_logic.push_settings,
+ )
+
+ def _perform_nes_frequency_change(self, nes_frequency: int) -> None:
+ """Applies the rate as one undo entry, then requests a retune of the now-stale samples.
+
+ The entry carries a rate-keyed coalesce target so the asynchronous per-sample retune
+ results fold back into this same ``SET_NES_FREQUENCY`` entry: one undo restores both the
+ prior rate and the prior reconstructions, and a later change to a different rate appends
+ a fresh entry.
+ """
+ with self._history.transaction(
+ HistoryAction.SET_NES_FREQUENCY,
+ detail=self._history_detail.value(nes_frequency),
+ coalesce=(nes_frequency,),
+ ):
+ self._sequencer_tracker_logic.set_nes_frequency(nes_frequency)
+
+ self._on_nes_frequency_changed(nes_frequency)
+
+ def nes_frequency_detail(self, nes_frequency: int) -> HistoryDetail:
+ """The history detail for a NES-frequency change, so the retune can reuse its undo entry."""
+ return self._history_detail.value(nes_frequency)
+
+ def _acknowledge_nes_frequency_changes(self) -> None:
+ self._nes_frequency_change_acknowledged = True
+
+ def _on_tracker_cell_focused(self) -> None:
+ """Drops the order cursor and sample selection when the tracker tracker takes focus.
+
+ The tracker, order, and samples panels each register a key-router scope active only while
+ it holds a selection; keeping a single selection across the three lets only the focused
+ panel consume keystrokes.
+ """
+ self._sequencer_order_panel.deselect_cell()
+ self._sequencer_voices_panel.deselect()
+
+ def _on_order_cell_focused(self) -> None:
+ """Drops the tracker cursor and sample selection when the order tracker takes focus."""
+ self._sequencer_tracker_panel.deselect_cell()
+ self._sequencer_voices_panel.deselect()
+
+ def create_tab(self) -> None:
+ """Builds this tab, which the layout holds and refits from here on."""
+ self._layout.create_tab()
+
+ def sync_responsive_layout(self) -> None:
+ """Refits this tab's columns to the current viewport, the entry the resize handler calls."""
+ self._layout.sync_responsive_layout()
+
+ @property
+ def player(self) -> AudioPlayerProtocol:
+ return self._guarded_player
+
+ def build_voice_actions(self) -> None:
+ """States the chosen voice's actions into the menu being built, for the bar's Voice group."""
+ self._sequencer_voices_panel.build_voice_actions()
+
+ @property
+ def edit_surfaces(self) -> Tuple[EditSurfaceProtocol, ...]:
+ """The panels offering editing gestures on what they hold selected.
+
+ The three hold one selection between them — a cursor in either grid, a row in the samples
+ list — so the menu bar reaches whichever one has it.
+ """
+ return (
+ self._sequencer_tracker_panel.edit_surface,
+ self._sequencer_order_panel.edit_surface,
+ self._sequencer_voices_panel,
+ )
diff --git a/src/sampletones_application/coordinators/tabs/sequencer/frames.py b/src/sampletones_application/coordinators/tabs/sequencer/frames.py
new file mode 100644
index 000000000..5a6b74a1b
--- /dev/null
+++ b/src/sampletones_application/coordinators/tabs/sequencer/frames.py
@@ -0,0 +1,144 @@
+from typing import Callable
+
+from sampletones_application.coordinators.tabs.sequencer.playhead import SequencerPlayhead
+from sampletones_application.logic.project.controller import ProjectController
+from sampletones_application.logic.sequencer.order import SequencerOrderLogic
+from sampletones_application.logic.sequencer.playback.playhead import (
+ remap_after_insert,
+ remap_after_move,
+ remap_after_remove,
+)
+from sampletones_application.logic.sequencer.playback.song_player import SongPlayerLogic
+from sampletones_application.logic.sequencer.tracker import SequencerTrackerLogic
+
+
+class SequencerFrames:
+ """The gestures that reshape the order, each carrying the playhead over what it moved.
+
+ Adding, removing and moving a frame shifts every frame after it, so a playhead standing on
+ one of them follows it to where it went, and the grid moves to the frame the reader is now
+ working on. A gesture that leaves the order's shape alone leaves the playhead alone too.
+ """
+
+ def __init__(
+ self,
+ order_logic: SequencerOrderLogic,
+ tracker_logic: SequencerTrackerLogic,
+ song_player_logic: SongPlayerLogic,
+ project_controller: ProjectController,
+ playhead: SequencerPlayhead,
+ ) -> None:
+ self._order_logic = order_logic
+ self._tracker_logic = tracker_logic
+ self._song_player_logic = song_player_logic
+ self._project_controller = project_controller
+ self._playhead = playhead
+
+ def remove(self, position: int) -> None:
+ """Takes a frame out of the order, carrying the playhead over the frames that close up."""
+ length_before = self._project_controller.order_length
+ self._order_logic.remove_from_order(position)
+ self._relocate_playhead(
+ lambda playhead: remap_after_remove(
+ playhead,
+ position,
+ length_before - 1,
+ )
+ )
+
+ def duplicate(self, position: int) -> None:
+ """Puts a copy of a frame after it, sharing the patterns the original names."""
+ self._order_logic.duplicate_frame(position)
+ self._settle_inserted_frame(position + 1)
+
+ def clone(self, position: int) -> None:
+ """Puts a copy of a frame after it, over patterns of its own."""
+ self._order_logic.clone_frame(position)
+ self._settle_inserted_frame(position + 1)
+
+ def insert(self, position: int) -> None:
+ """Puts an empty frame after a frame, and moves to it."""
+ self._order_logic.insert_frame(position + 1)
+ self._relocate_playhead(
+ lambda playhead: remap_after_insert(
+ playhead,
+ position + 1,
+ )
+ )
+ self._select_frame_when_idle(position + 1)
+
+ def clear(self, position: int) -> None:
+ """Clears every channel in the frame; no index shift, so the playhead is left in place.
+
+ A sounding voice keeps ringing across the now-empty frame (only an explicit note-off cuts it).
+ """
+ self._order_logic.clear_frame(position)
+
+ def move(self, from_position: int, to_position: int) -> None:
+ """Moves a frame to another place in the order, and shows it where it landed."""
+ self._order_logic.move_frame(from_position, to_position)
+ self._relocate_playhead(
+ lambda playhead: remap_after_move(
+ playhead,
+ from_position,
+ to_position,
+ )
+ )
+ self._tracker_logic.select_frame(to_position)
+
+ def play_from(self, position: int) -> None:
+ """Plays from a frame: relocates the playhead when already playing, else starts there."""
+ if self._song_player_logic.is_playing():
+ self._song_player_logic.seek(position)
+ else:
+ self._song_player_logic.play_from(position)
+
+ def select(self, frame_index: int) -> None:
+ """Selects an order frame in the tracker, and moves the playhead too when following.
+
+ While the view follows the playhead, choosing another order during playback relocates the
+ playhead to it (the seek no-ops when stopped); otherwise the selection only changes which
+ pattern is edited, leaving playback where it is.
+ """
+ self._tracker_logic.select_frame(frame_index)
+ if self._song_player_logic.follow_mode.follows_pattern:
+ self._song_player_logic.seek(frame_index)
+
+ def _settle_inserted_frame(self, position: int) -> None:
+ """Carries the playhead and the shown frame over a frame that has just been inserted.
+
+ A frame arriving at ``position`` pushes every later frame one along, so a playhead
+ standing on one of them follows it, and the grid moves to the new frame for the reader
+ to work on.
+ """
+ self._relocate_playhead(
+ lambda playhead: remap_after_insert(
+ playhead,
+ position,
+ )
+ )
+ self._select_frame_when_idle(position)
+
+ def _relocate_playhead(self, remap: Callable[[int], int]) -> None:
+ """Keeps the live playhead on the frame it was sounding after a structural order edit.
+
+ Both grids take the new position straight away, ahead of the worker's next row update, so
+ rapid edits (e.g. a held Alt+arrow) stay in step, and a paused playhead — which reports no
+ further rows — is marked on the frame the edit moved it to.
+ """
+ position = self._playhead.position
+ if position is None:
+ return
+
+ order_position = remap(position.order_position)
+ if order_position == position.order_position:
+ return
+
+ self._playhead.stand_at(order_position, position.row_index)
+ self._song_player_logic.relocate(order_position)
+ self._playhead.mark()
+
+ def _select_frame_when_idle(self, frame_index: int) -> None:
+ """Moves the editor selection to a frame, unless playback is actively driving it."""
+ if not self._song_player_logic.is_playing():
+ self._tracker_logic.select_frame(frame_index)
diff --git a/src/sampletones_application/coordinators/tabs/sequencer/history.py b/src/sampletones_application/coordinators/tabs/sequencer/history.py
new file mode 100644
index 000000000..587c0290e
--- /dev/null
+++ b/src/sampletones_application/coordinators/tabs/sequencer/history.py
@@ -0,0 +1,174 @@
+from typing import Callable, Optional, ParamSpec
+
+from sampletones_application.categories.hierarchy import Page, Panel, TextType
+from sampletones_application.categories.manager import LanguageManager
+from sampletones_application.logic.history.action import HistoryAction
+from sampletones_application.logic.history.manager import HistoryManager
+from sampletones_application.logic.history.transaction import CoalesceKey
+from sampletones_application.logic.project.controller import ProjectController
+from sampletones_application.logic.sequencer.tracker import SequencerTrackerLogic
+from sampletones_application.view_model.sequencer.history import (
+ HistoryEntryViewModel,
+ HistoryViewModel,
+)
+from sampletones_application.view_model.sequencer.region import TrackerRegion
+from sampletones_application.view_model.shared.history import HistoryDetail
+from sampletones_core.constants.enums import ChannelName
+
+_GestureParams = ParamSpec("_GestureParams")
+
+
+class SequencerHistoryRecorder:
+ """The path every sequencer gesture takes into the project history.
+
+ A gesture is handed here as the callback the panel fires, and comes back wrapped in the
+ transaction that records the single entry undoing it. What that entry says, and which target
+ it coalesces onto, are computed from the very arguments the gesture receives, so a hook keeps
+ the signature its panel calls it with.
+
+ The panel's own view of the stack is built here too, since the labels an entry prints and the
+ action it was recorded under are read from the same place.
+ """
+
+ def __init__(
+ self,
+ history: HistoryManager,
+ project_controller: ProjectController,
+ tracker_logic: SequencerTrackerLogic,
+ *,
+ language_manager: LanguageManager,
+ ) -> None:
+ self._history = history
+ self._project_controller = project_controller
+ self._tracker_logic = tracker_logic
+ self._language_manager = language_manager
+
+ def undoable(
+ self,
+ action: HistoryAction,
+ callback: Callable[_GestureParams, None],
+ *,
+ detail: Optional[Callable[_GestureParams, HistoryDetail]] = None,
+ coalesce: Optional[Callable[_GestureParams, CoalesceKey]] = None,
+ ) -> Callable[_GestureParams, None]:
+ """Wraps a state-changing hook so its whole gesture becomes one undo entry.
+
+ Every mutation the wrapped callback triggers is grouped under ``action``;
+ a gesture that changes nothing records no entry. ``detail`` computes the
+ entry's colored description segments from the same arguments the hook
+ receives, and ``coalesce`` computes the gesture's target key from them:
+ consecutive gestures sharing the same action and target collapse into a
+ single entry.
+
+ The gesture is batched inside its transaction, so however many rows it
+ writes, the panels rebuild once — and they rebuild before the entry that
+ undoes them is recorded, because the snapshot reads the project rather
+ than the views.
+ """
+
+ def wrapped(
+ *args: _GestureParams.args,
+ **kwargs: _GestureParams.kwargs,
+ ) -> None:
+ description = detail(*args, **kwargs) if detail is not None else ()
+ key = coalesce(*args, **kwargs) if coalesce is not None else None
+ with (
+ self._history.transaction(
+ action,
+ detail=description,
+ coalesce=key,
+ ),
+ self._project_controller.batch(),
+ ):
+ callback(*args, **kwargs)
+
+ return wrapped
+
+ def cell_key(
+ self,
+ row_index: int,
+ channel: Optional[ChannelName],
+ ) -> CoalesceKey:
+ """Identifies one cell of the displayed frame as a coalescing target.
+
+ The sample column (``channel`` absent) is its own target, distinct from
+ every channel column.
+ """
+ channel_key = channel if channel is not None else ""
+ return (self._tracker_logic.frame_index, channel_key, row_index)
+
+ def note_key(
+ self,
+ row_index: int,
+ channel: ChannelName,
+ _pitch: int,
+ ) -> CoalesceKey:
+ """Identifies the cell a typed note landed in, so retyping one note coalesces onto it."""
+ return self.cell_key(row_index, channel)
+
+ def adjustment_key(
+ self,
+ region: TrackerRegion,
+ _delta: int,
+ ) -> CoalesceKey:
+ """Identifies the cells an adjustment covers as one coalescing target.
+
+ A streak of nudges over the same block reads as one entry, so holding a transpose key steps
+ the selection and leaves a single step to undo; moving the cursor or reaching the selection
+ out starts the next one.
+ """
+ return (
+ self._tracker_logic.frame_index,
+ region.first_row,
+ region.last_row,
+ region.first_slot,
+ region.last_slot,
+ )
+
+ def edit_row_key(
+ self,
+ row_index: int,
+ channel: Optional[ChannelName],
+ voice_id: Optional[str],
+ transpose: Optional[int],
+ volume: Optional[int],
+ ) -> CoalesceKey:
+ """Extends the cell target with the subcolumns the edit writes.
+
+ Consecutive edits of one cell coalesce only when they write the same
+ subcolumns, so entering a note and then tweaking its volume stay
+ separate entries.
+ """
+ return (
+ *self.cell_key(row_index, channel),
+ voice_id is not None,
+ transpose is not None,
+ volume is not None,
+ )
+
+ def module_setting_key(self, _value: int) -> CoalesceKey:
+ """Marks a module-wide setting as one target, shared by its whole streak."""
+ return ()
+
+ def view_model(self) -> HistoryViewModel:
+ """The stack as the history panel draws it, each entry labeled by its action."""
+ cursor = self._history.cursor
+ entries = tuple(
+ HistoryEntryViewModel(
+ index=index,
+ label=self._action_label(entry.action),
+ detail_segments=entry.detail,
+ is_current=index == cursor,
+ is_future=index > cursor,
+ )
+ for index, entry in enumerate(self._history.entries)
+ )
+ return HistoryViewModel(entries=entries, cursor=cursor)
+
+ def _action_label(self, action: HistoryAction) -> str:
+ return self._language_manager[
+ Page.SEQUENCER,
+ Panel.HISTORY,
+ TextType.LABEL,
+ action,
+ ]
diff --git a/src/sampletones_application/coordinators/tabs/sequencer/layout.py b/src/sampletones_application/coordinators/tabs/sequencer/layout.py
new file mode 100644
index 000000000..8353cf870
--- /dev/null
+++ b/src/sampletones_application/coordinators/tabs/sequencer/layout.py
@@ -0,0 +1,190 @@
+import dearpygui.dearpygui as dpg
+
+from sampletones_application.categories.manager import LanguageManager
+from sampletones_application.config.managers.session import SessionManager
+from sampletones_application.parameters.sequencer import SequencerTabParameters
+from sampletones_application.tags.compose import compose_tag
+from sampletones_application.tags.general import (
+ SUF_PANEL_CENTER,
+ SUF_PANEL_LEFT,
+ SUF_PANEL_RIGHT,
+ TAG_GLOBAL_TAB_SEQUENCER,
+ TAG_GLOBAL_TABS,
+ TAG_GLOBAL_THEME_DEFAULT,
+ TAG_GLOBAL_THEME_PANEL_GROUND,
+ TAG_GLOBAL_THEME_PANEL_SURFACE,
+)
+from sampletones_application.tags.sequencer import TAG_SEQUENCER_HISTORY_PANEL
+from sampletones_application.ui.elements.layout.columns import ColumnSpec, TabColumns
+from sampletones_application.ui.elements.layout.responsive import expanded_side_width
+from sampletones_application.ui.panels.sequencer.browser import GUISequencerBrowserPanel
+from sampletones_application.ui.panels.sequencer.history import GUISequencerHistoryPanel
+from sampletones_application.ui.panels.sequencer.module import GUISequencerModulePanel
+from sampletones_application.ui.panels.sequencer.order.panel import GUISequencerOrderPanel
+from sampletones_application.ui.panels.sequencer.tracker.panel import GUISequencerTrackerPanel
+from sampletones_application.ui.panels.sequencer.voices.panel import GUISequencerVoicesPanel
+from sampletones_application.ui.themes.registry import ThemeRegistry
+from sampletones_application.utils.gui.dpg import dpg_configure_item
+
+LEFT_COLUMN_TAG = compose_tag(TAG_GLOBAL_TAB_SEQUENCER, SUF_PANEL_LEFT)
+CENTER_COLUMN_TAG = compose_tag(TAG_GLOBAL_TAB_SEQUENCER, SUF_PANEL_CENTER)
+RIGHT_COLUMN_TAG = compose_tag(TAG_GLOBAL_TAB_SEQUENCER, SUF_PANEL_RIGHT)
+
+
+class SequencerTabLayout:
+ """The three columns this tab stands in, and how they refit as the window and cards change.
+
+ The browser fills the left column, the two grids stack down the center, and the module,
+ samples and history cards stack down the right. A column keeps its share of the viewport as
+ the window is resized, and a card that collapses hands its space back to the card above it.
+
+ Each collapse is written to the session as it happens, so the tab opens next launch showing
+ exactly the cards it was left showing.
+ """
+
+ def __init__(
+ self,
+ browser_panel: GUISequencerBrowserPanel,
+ order_panel: GUISequencerOrderPanel,
+ tracker_panel: GUISequencerTrackerPanel,
+ module_panel: GUISequencerModulePanel,
+ voices_panel: GUISequencerVoicesPanel,
+ history_panel: GUISequencerHistoryPanel,
+ *,
+ layout: SequencerTabParameters,
+ session_manager: SessionManager,
+ language_manager: LanguageManager,
+ ) -> None:
+ self._browser_panel = browser_panel
+ self._order_panel = order_panel
+ self._tracker_panel = tracker_panel
+ self._module_panel = module_panel
+ self._voices_panel = voices_panel
+ self._history_panel = history_panel
+ self._session_manager = session_manager
+ self._language_manager = language_manager
+ self._geometry = layout.geometry
+ self._side_panel_count: int
+ self._instruments_width = layout.right_column_width
+ self._right_height = layout.right_column_height
+ self._history_expanded_height = layout.history_height
+ self._history_collapsed_footprint = layout.header_bar_height + 2 * self._geometry.panel_gap
+ self._inter_card_gap = self._stacked_card_gap()
+
+ def create_tab(self) -> None:
+ """Builds the tab and the three columns its panels are drawn into."""
+ with dpg.tab(
+ tag=TAG_GLOBAL_TAB_SEQUENCER,
+ parent=TAG_GLOBAL_TABS,
+ label=self._language_manager["global.menu.label.tab_sequencer"],
+ ):
+ self._side_panel_count = TabColumns.build(
+ panel_gap=self._geometry.panel_gap,
+ columns=[
+ ColumnSpec(
+ tag=LEFT_COLUMN_TAG,
+ build=self._browser_panel.create_panel,
+ theme=TAG_GLOBAL_THEME_PANEL_SURFACE,
+ width=self._geometry.side_width,
+ height=self._geometry.side_height,
+ no_scrollbar=True,
+ ),
+ ColumnSpec(
+ tag=CENTER_COLUMN_TAG,
+ build=self._build_center_column,
+ theme=TAG_GLOBAL_THEME_PANEL_GROUND,
+ border=False,
+ ),
+ ColumnSpec(
+ tag=RIGHT_COLUMN_TAG,
+ build=self._build_right_column,
+ theme=TAG_GLOBAL_THEME_PANEL_GROUND,
+ width=self._instruments_width,
+ height=self._right_height,
+ border=False,
+ no_scrollbar=True,
+ ),
+ ],
+ )
+
+ self._sync_browser_width()
+
+ def sync_responsive_layout(self) -> None:
+ """Refits this tab's side column to the current viewport, the entry the resize handler calls."""
+ self._sync_browser_width()
+
+ def on_card_collapse_changed(self, card_tag: str, collapsed: bool) -> None:
+ """Persists a card's collapsed state so it restores on the next launch."""
+ self._session_manager.set_card_collapsed(card_tag, collapsed)
+ if card_tag == TAG_SEQUENCER_HISTORY_PANEL:
+ self._sync_voices_height()
+
+ def on_browser_collapse_changed(self, card_tag: str, collapsed: bool) -> None:
+ """Persists the browser panel's collapse, then docks or restores the width of the column it fills."""
+ self._session_manager.set_card_collapsed(card_tag, collapsed)
+ self._sync_browser_width()
+
+ def on_browser_favorites_filter_changed(self, panel_tag: str, favorites_only: bool) -> None:
+ """Persists the browser's favorites filter so it opens in the same mode on the next launch."""
+ self._session_manager.set_favorites_filter_active(panel_tag, favorites_only)
+
+ def _build_center_column(self, parent: str) -> None:
+ """Stacks the order table and tracker tracker down the center column."""
+ self._order_panel.create_panel(parent)
+ dpg.add_spacer(height=self._geometry.panel_gap, parent=parent)
+ self._tracker_panel.create_panel(parent)
+
+ def _build_right_column(self, parent: str) -> None:
+ """Stacks the module settings, samples, and history cards in the right column."""
+ self._module_panel.create_panel(parent)
+ dpg.add_spacer(height=self._geometry.panel_gap)
+ self._voices_panel.create_panel(parent)
+ dpg.add_spacer(height=self._geometry.panel_gap)
+ self._history_panel.create_panel(parent)
+ self._sync_voices_height()
+
+ def _sync_browser_width(self) -> None:
+ """Shrinks the browser column to the collapse rail when collapsed, else sizes it to the viewport width."""
+ if self._browser_panel.collapsed:
+ width = self._geometry.rail_width
+ else:
+ width = expanded_side_width(
+ self._geometry.side_width,
+ dpg.get_viewport_client_width(),
+ self._geometry.baseline_viewport_width,
+ self._side_panel_count,
+ self._geometry.center_weight,
+ )
+
+ dpg_configure_item(LEFT_COLUMN_TAG, width=width)
+
+ def _stacked_card_gap(self) -> int:
+ """The rendered vertical gap between two cards stacked in the right column.
+
+ The cards are separated by a ``panel_gap`` spacer, but DearPyGui also lays its ``ItemSpacing.y``
+ on each side of that spacer, so the real gap is the spacer plus two of those spacings. The
+ spacing is read from the base theme, which sets it explicitly, so the gap tracks the theme's
+ value.
+ """
+ spacing = ThemeRegistry.get(TAG_GLOBAL_THEME_DEFAULT).get_style(
+ dpg.mvAll,
+ dpg.mvStyleVar_ItemSpacing,
+ )
+ spacing_y = int(spacing[1]) if spacing is not None else 0
+ return self._geometry.panel_gap + 2 * spacing_y
+
+ def _sync_voices_height(self) -> None:
+ """Reserves the bottom space the history card and its inter-card gap occupy, so samples fills the rest.
+
+ The samples card fills the right column above the history card by reserving that footprint below
+ it. History carries its own height in both states — filling the reservation while expanded, pinned
+ to its header bar while collapsed — so this only has to size the reservation: the expanded history
+ height, or the collapsed bar footprint. The reservation clears the full inter-card gap (see
+ :meth:`_stacked_card_gap`) so the collapsed bar lands flush at the column bottom.
+ """
+ if self._history_panel.collapsed:
+ footprint = self._history_collapsed_footprint
+ else:
+ footprint = self._history_expanded_height
+
+ self._voices_panel.set_expanded_height(-(self._inter_card_gap + footprint))
diff --git a/src/sampletones_application/coordinators/tabs/sequencer/playhead.py b/src/sampletones_application/coordinators/tabs/sequencer/playhead.py
new file mode 100644
index 000000000..6c155a2c1
--- /dev/null
+++ b/src/sampletones_application/coordinators/tabs/sequencer/playhead.py
@@ -0,0 +1,49 @@
+from typing import Optional
+
+from sampletones_application.ui.panels.sequencer.order.panel import GUISequencerOrderPanel
+from sampletones_application.ui.panels.sequencer.tracker.panel import GUISequencerTrackerPanel
+from sampletones_core.project.song_position import SongPosition
+
+
+class SequencerPlayhead:
+ """Where the song is sounding, and the marks both grids carry to show it.
+
+ The order grid marks the frame the playhead sounds; the tracker takes the whole position,
+ since the row it marks belongs to the pattern of that frame. Standing the playhead somewhere
+ and marking it are separate steps, so a caller that also moves the shown frame can do that
+ first and leave the marks landing on the pattern already in view.
+ """
+
+ def __init__(
+ self,
+ tracker_panel: GUISequencerTrackerPanel,
+ order_panel: GUISequencerOrderPanel,
+ ) -> None:
+ self._tracker_panel = tracker_panel
+ self._order_panel = order_panel
+ self._position: Optional[SongPosition] = None
+
+ @property
+ def position(self) -> Optional[SongPosition]:
+ """The position the song is sounding, absent while nothing is."""
+ return self._position
+
+ def stand_at(self, order_position: int, row_index: int) -> None:
+ """Puts the playhead on a row of a frame, leaving the marks for :meth:`mark`."""
+ self._position = SongPosition(
+ order_position=order_position,
+ row_index=row_index,
+ )
+
+ def stop(self) -> None:
+ """Takes the playhead off the song, clearing the marks it carried."""
+ self._position = None
+ self.mark()
+
+ def mark(self) -> None:
+ """Puts the playhead's marks where it stands, on both grids."""
+ position = self._position
+ self._tracker_panel.set_playing_position(position)
+ self._order_panel.set_playing_position(
+ position.order_position if position is not None else None,
+ )
diff --git a/src/sampletones_application/coordinators/tabs/sequencer/project.py b/src/sampletones_application/coordinators/tabs/sequencer/project.py
new file mode 100644
index 000000000..12bc642af
--- /dev/null
+++ b/src/sampletones_application/coordinators/tabs/sequencer/project.py
@@ -0,0 +1,37 @@
+from sampletones_application.categories.manager import LanguageManager
+from sampletones_application.logic.project.controller import ProjectController
+from sampletones_application.tags.general import TAG_GLOBAL_DIALOG_NO_PROJECT_OPEN
+from sampletones_application.utils.gui.dialogs import DialogsRenderer
+
+
+class OpenProjectRequirement:
+ """What a gesture needing somewhere to put its result asks before it starts.
+
+ A voice, a reconstruction and an import all land in the open project, so each asks here
+ first. Answering that nothing is open also tells the reader so, leaving the caller with
+ only the decision to stop.
+ """
+
+ def __init__(
+ self,
+ project_controller: ProjectController,
+ *,
+ dialogs: DialogsRenderer,
+ language_manager: LanguageManager,
+ ) -> None:
+ self._project_controller = project_controller
+ self._dialogs = dialogs
+ self._message = language_manager["global.dialog.message.no_project_open"]
+ self._title = language_manager["global.dialog.title.no_project_open"]
+
+ def met(self) -> bool:
+ """Whether a project stands open, showing the notice when none does."""
+ if self._project_controller.is_open:
+ return True
+
+ self._dialogs.show_info(
+ TAG_GLOBAL_DIALOG_NO_PROJECT_OPEN,
+ self._message,
+ self._title,
+ )
+ return False
diff --git a/src/sampletones_application/coordinators/tabs/sequencer/reconstructions.py b/src/sampletones_application/coordinators/tabs/sequencer/reconstructions.py
new file mode 100644
index 000000000..905d75825
--- /dev/null
+++ b/src/sampletones_application/coordinators/tabs/sequencer/reconstructions.py
@@ -0,0 +1,255 @@
+from pathlib import Path
+from typing import Callable, Optional
+
+from sampletones_application.categories.hierarchy import Tab
+from sampletones_application.categories.manager import LanguageManager
+from sampletones_application.coordinators.tabs.sequencer.project import OpenProjectRequirement
+from sampletones_application.logic.history.action import HistoryAction
+from sampletones_application.logic.history.manager import HistoryManager
+from sampletones_application.logic.project.controller import ProjectController
+from sampletones_application.logic.sequencer.browser import SequencerBrowserLogic
+from sampletones_application.logic.sequencer.history_detail import SequencerHistoryDetail
+from sampletones_application.logic.sequencer.tracker import SequencerTrackerLogic
+from sampletones_application.logic.sequencer.voices import SequencerVoicesLogic
+from sampletones_application.tags.sequencer import TAG_SEQUENCER_BROWSER_DIALOG_FREQUENCY
+from sampletones_application.ui.panels.sequencer.voices.panel import GUISequencerVoicesPanel
+from sampletones_application.utils.gui.dialogs import DialogsRenderer
+from sampletones_core.reconstructions import Reconstruction
+from sampletones_shared.exceptions import SampleToNESError
+from sampletones_shared.logger import logger
+
+
+class SequencerReconstructions:
+ """How a reconstruction joins the pool, or takes the place of one already in it.
+
+ A reconstruction renders at the NES frequency it was generated at, so every gesture here
+ settles that rate against the project's before it commits: equal rates go straight through,
+ a mismatch the project can absorb adopts the incoming rate, and one it cannot is put to the
+ reader with both rates named.
+
+ The rate and what it times are settled in one history entry, so a single undo restores the
+ project's previous rate together with the audio it was timing.
+ """
+
+ def __init__(
+ self,
+ browser_logic: SequencerBrowserLogic,
+ tracker_logic: SequencerTrackerLogic,
+ voices_logic: SequencerVoicesLogic,
+ voices_panel: GUISequencerVoicesPanel,
+ history: HistoryManager,
+ history_detail: SequencerHistoryDetail,
+ project_controller: ProjectController,
+ open_project: OpenProjectRequirement,
+ *,
+ dialogs: DialogsRenderer,
+ language_manager: LanguageManager,
+ on_tab_switch: Callable[[Tab], None],
+ on_sample_reconstruction_replaced: Callable[[str, Reconstruction], None],
+ ) -> None:
+ self._browser_logic = browser_logic
+ self._tracker_logic = tracker_logic
+ self._voices_logic = voices_logic
+ self._voices_panel = voices_panel
+ self._history = history
+ self._history_detail = history_detail
+ self._project_controller = project_controller
+ self._open_project = open_project
+ self._dialogs = dialogs
+ self._language_manager = language_manager
+ self._on_tab_switch = on_tab_switch
+ self._on_sample_reconstruction_replaced = on_sample_reconstruction_replaced
+
+ def import_from_file(self, filepath: Path) -> None:
+ """Reads a reconstruction file and brings what it holds into the pool as a sample."""
+ if not self._open_project.met():
+ return
+
+ reconstruction = self._loaded(filepath)
+ if reconstruction is None:
+ return
+
+ self._add_with_frequency_check(reconstruction, filepath.stem)
+
+ def import_object(self, reconstruction: Reconstruction, name: str) -> None:
+ """Adds an in-memory reconstruction — the one open in the Reconstruction tab — as a sample.
+
+ The sample embeds an independent copy, so the open document keeps its own source-audio
+ location and file backing while the project stores a self-contained, detached sample.
+ """
+ if not self._open_project.met():
+ return
+
+ self._add_with_frequency_check(
+ reconstruction.model_copy(deep=True),
+ name,
+ )
+
+ def replace_from_file(self, filepath: Path) -> None:
+ """Substitutes the selected sample's reconstruction with a browser file's.
+
+ The sample keeps its id and position, so every pattern row referencing it sounds the
+ incoming audio while the tracker shows it where it was, and it takes the file's name the way
+ an import does. The target is whatever the samples panel has selected as the gesture starts,
+ which is also what named the menu item the user clicked.
+ """
+ selection = self._voices_panel.selection
+ if selection is None:
+ return
+
+ reconstruction = self._loaded(filepath)
+ if reconstruction is None:
+ return
+
+ self._reconcile_nes_frequency(
+ reconstruction,
+ lambda adopt_frequency: self._commit_replace(
+ selection.voice_id,
+ reconstruction,
+ filepath.stem,
+ adopt_frequency=adopt_frequency,
+ ),
+ can_adopt_frequency=self._project_controller.voice_count == 1,
+ )
+
+ def replace_target_label(self) -> Optional[str]:
+ """The indexed label of the sample a browser replacement would overwrite, while one is selected."""
+ selection = self._voices_panel.selection
+ if selection is None:
+ return None
+
+ return selection.label
+
+ def _loaded(self, filepath: Path) -> Optional[Reconstruction]:
+ """Reads a reconstruction file, reporting one the reader cannot take.
+
+ Returns:
+ Optional[Reconstruction]: What the file holds, or ``None`` once the failure has been
+ shown.
+ """
+ try:
+ return self._browser_logic.load_reconstruction(filepath)
+ except (SampleToNESError, OSError) as exception:
+ logger.error_with_traceback(
+ exception,
+ f"Failed to load reconstruction from {filepath}",
+ )
+ self._dialogs.show_error(exception)
+
+ return None
+
+ def _add_with_frequency_check(
+ self,
+ reconstruction: Reconstruction,
+ name: str,
+ ) -> None:
+ """Adds a loaded reconstruction once its NES frequency is settled against the project's.
+
+ An empty project adopts the incoming frequency, since the rate times nothing there yet; a
+ project that already holds samples confirms first, because the rate governs how all of them
+ play back.
+ """
+ self._reconcile_nes_frequency(
+ reconstruction,
+ lambda adopt_frequency: self._commit_add(
+ reconstruction,
+ name,
+ adopt_frequency=adopt_frequency,
+ ),
+ can_adopt_frequency=not self._project_controller.has_voices,
+ )
+
+ def _reconcile_nes_frequency(
+ self,
+ reconstruction: Reconstruction,
+ commit: Callable[[Optional[int]], None],
+ *,
+ can_adopt_frequency: bool,
+ ) -> None:
+ """Settles a reconstruction's NES frequency against the project's, then commits the gesture.
+
+ A reconstruction renders at the frequency it was generated at, so bringing one recorded at
+ another rate into the project plays it back wrong. Equal rates commit straight away. A
+ mismatch the project can absorb — because the rate times nothing that outlives the gesture —
+ adopts the incoming rate. Otherwise the user decides, seeing both rates, and confirming
+ keeps the project's rate for the samples already timed by it.
+
+ Args:
+ reconstruction: The reconstruction being brought into the project.
+ commit: Performs the gesture, receiving the frequency to adopt, or ``None`` to keep the
+ project's.
+ can_adopt_frequency: Whether adopting the incoming rate re-times only what this gesture
+ itself brings in, which settles a mismatch without asking.
+ """
+ reconstruction_frequency = reconstruction.config.nes_frequency
+ project_frequency = self._tracker_logic.settings.nes_frequency
+
+ if reconstruction_frequency == project_frequency:
+ commit(None)
+ return
+
+ if can_adopt_frequency:
+ commit(reconstruction_frequency)
+ return
+
+ self._dialogs.show_confirmation(
+ tag=TAG_SEQUENCER_BROWSER_DIALOG_FREQUENCY,
+ title=self._language_manager["global.dialog.title.frequency_mismatch"],
+ message=self._language_manager["global.dialog.message.frequency_mismatch"].format(
+ reconstruction=reconstruction_frequency,
+ project=project_frequency,
+ ),
+ on_confirm=lambda: commit(None),
+ ok_label=self._language_manager["global.dialog.label.add_anyway"],
+ )
+
+ def _commit_add(
+ self,
+ reconstruction: Reconstruction,
+ name: str,
+ *,
+ adopt_frequency: Optional[int],
+ ) -> None:
+ """Adds a reconstruction as one undoable gesture, optionally adopting its frequency.
+
+ The frequency reconciliation and the sample insertion form a single history
+ entry, so undoing a freshly-imported sample also restores the prior rate.
+ """
+ with self._history.transaction(
+ HistoryAction.ADD_SAMPLE,
+ detail=self._history_detail.add_sample(name),
+ ):
+ if adopt_frequency is not None:
+ self._tracker_logic.set_nes_frequency(adopt_frequency)
+ self._browser_logic.add_reconstruction(reconstruction, name)
+
+ self._on_tab_switch(Tab.SEQUENCER)
+
+ def _commit_replace(
+ self,
+ voice_id: str,
+ reconstruction: Reconstruction,
+ name: str,
+ *,
+ adopt_frequency: Optional[int],
+ ) -> None:
+ """Substitutes a sample's reconstruction as one undoable gesture, renaming it to the source.
+
+ The detail is composed while the sample still holds the outgoing reconstruction, so it reads
+ the name being replaced alongside the incoming one. The replacement is announced in the same
+ window, ahead of the substitution, because an editor holding the sample open recognizes it by
+ the identity of the reconstruction it is about to give up. The frequency adoption, the rename,
+ and the substitution share a single history entry, so one undo restores the previous rate,
+ name, and audio together.
+ """
+ detail = self._history_detail.replace_sample(voice_id, name)
+ with self._history.transaction(
+ HistoryAction.REPLACE_SAMPLE,
+ detail=detail,
+ ):
+ if adopt_frequency is not None:
+ self._tracker_logic.set_nes_frequency(adopt_frequency)
+
+ self._voices_logic.rename_voice(voice_id, name)
+ self._on_sample_reconstruction_replaced(voice_id, reconstruction)
+ self._browser_logic.replace_reconstruction(voice_id, reconstruction)
diff --git a/src/sampletones_application/coordinators/tabs/sequencer/voices.py b/src/sampletones_application/coordinators/tabs/sequencer/voices.py
new file mode 100644
index 000000000..d6ec751a4
--- /dev/null
+++ b/src/sampletones_application/coordinators/tabs/sequencer/voices.py
@@ -0,0 +1,239 @@
+from pathlib import Path
+from typing import Callable, Optional
+
+from sampletones_application.categories.instrument import InstrumentImportMessages
+from sampletones_application.categories.manager import LanguageManager
+from sampletones_application.config.managers.session import SessionManager
+from sampletones_application.coordinators.tabs.sequencer.project import OpenProjectRequirement
+from sampletones_application.logic.history.action import HistoryAction
+from sampletones_application.logic.history.manager import HistoryManager
+from sampletones_application.logic.project.controller import ProjectController
+from sampletones_application.logic.sequencer.history_detail import SequencerHistoryDetail
+from sampletones_application.logic.sequencer.voices import SequencerVoicesLogic
+from sampletones_application.tags.general import TAG_GLOBAL_DIALOG_INSTRUMENT_IMPORTED
+from sampletones_application.tags.sequencer import TAG_SEQUENCER_VOICES_DIALOG_REMOVE
+from sampletones_application.utils.file_dialogs.api import open_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.constants.enums import ChannelName
+from sampletones_core.formats.famitracker.voice import ImportedVoice
+from sampletones_core.utils.display import display_id
+from sampletones_shared.exceptions import LoadInstrumentError
+from sampletones_shared.logger import logger
+from sampletones_shared.paths.extensions import EXT_FILE_INSTRUMENT, EXT_FILE_RECONSTRUCTION
+
+
+class SequencerVoices:
+ """The gestures that change what the pool holds: adding, importing, removing, renaming.
+
+ Every one of them is a whole gesture, so each records the single history entry that takes the
+ pool back to where it stood. A gesture reaching a file reads it before the pool is touched,
+ which leaves a file the reader cannot use with the project and the history as they were.
+ """
+
+ def __init__(
+ self,
+ voices_logic: SequencerVoicesLogic,
+ history: HistoryManager,
+ history_detail: SequencerHistoryDetail,
+ project_controller: ProjectController,
+ session_manager: SessionManager,
+ open_project: OpenProjectRequirement,
+ *,
+ dialogs: DialogsRenderer,
+ language_manager: LanguageManager,
+ import_messages: InstrumentImportMessages,
+ import_reconstruction: Callable[[Path], None],
+ ) -> None:
+ self._voices_logic = voices_logic
+ self._history = history
+ self._history_detail = history_detail
+ self._project_controller = project_controller
+ self._session_manager = session_manager
+ self._open_project = open_project
+ self._dialogs = dialogs
+ self._language_manager = language_manager
+ self._import_messages = import_messages
+ self._import_reconstruction = import_reconstruction
+
+ def add_instrument(self) -> None:
+ """Appends a hand-written voice, named for the position it takes in the list.
+
+ An instrument arrives sustaining at full volume, so it plays as soon as it is placed and the
+ envelopes stay the reader's to write; naming it by its position gives the list a readable
+ entry until they rename it.
+ """
+ name = self._language_manager["sequencer.voices.template.instrument_name"].format(
+ position=display_id(self._project_controller.voice_count),
+ )
+ with self._history.transaction(
+ HistoryAction.ADD_INSTRUMENT,
+ detail=self._history_detail.add_instrument(name),
+ ):
+ self._voices_logic.add_new_instrument(name)
+
+ def add_instrument_from_channel(
+ self,
+ voice_id: str,
+ channel_name: ChannelName,
+ ) -> None:
+ """Takes what one channel of a voice plays as an instrument of its own, then opens it.
+
+ A recording states its channels as frames, and this reads one of them back as envelopes,
+ so what the conversion found becomes a voice the reader edits by hand. The new voice is
+ brought up where it is edited, since seeing those envelopes is what taking the channel out
+ was for.
+
+ Args:
+ voice_id: The voice the channel belongs to.
+ channel_name: The channel whose envelopes the instrument takes.
+ """
+ instrument = self._voices_logic.instrument_from_channel(voice_id, channel_name)
+ if instrument is None:
+ return
+
+ with self._history.transaction(
+ HistoryAction.ADD_INSTRUMENT,
+ detail=self._history_detail.add_instrument(instrument.name),
+ ):
+ self._voices_logic.add_instrument(instrument)
+
+ self._voices_logic.request_edit(instrument.id)
+
+ def add_sample_from_file(self) -> None:
+ """Brings a reconstruction saved anywhere on disk into the pool as a sample.
+
+ The tree beside the list reaches the reconstructions folder, so a file kept elsewhere
+ arrives through the system's own browser, which opens on the folder the last one came
+ from.
+ """
+ filepath = open_file_dialog(
+ title=self._language_manager["sequencer.voices.title.add_sample_dialog"],
+ initial_directory=self._session_manager.get_reconstruction_path(),
+ filters=(
+ FileFilter.for_extensions(
+ self._language_manager["global.dialog.filter.reconstruction"],
+ [EXT_FILE_RECONSTRUCTION],
+ ),
+ ),
+ )
+
+ self._import_located_reconstruction(filepath)
+
+ @ignore_none_path
+ def _import_located_reconstruction(self, filepath: Path) -> None:
+ self._session_manager.set_reconstruction_path(filepath.parent)
+ self._import_reconstruction(filepath)
+
+ def import_instrument(self) -> None:
+ """Brings a FamiTracker instrument file into the pool as an instrument voice.
+
+ The file arrives through the system's own browser, which opens on the folder the last
+ instrument was written to or read from, so an export and the import that follows it meet
+ in one place. A project is asked for first, since a voice needs a pool to land in.
+ """
+ if not self._open_project.met():
+ return
+
+ filepath = open_file_dialog(
+ title=self._language_manager["sequencer.voices.title.import_instrument_dialog"],
+ initial_directory=self._session_manager.get_instrument_path(),
+ filters=(
+ FileFilter.for_extensions(
+ self._language_manager["global.dialog.filter.famitracker_instrument"],
+ [EXT_FILE_INSTRUMENT],
+ ),
+ ),
+ )
+
+ self._import_located_instrument(filepath)
+
+ @ignore_none_path
+ def _import_located_instrument(self, filepath: Path) -> None:
+ """Reads a located instrument file into the pool, then reports what it held.
+
+ The file is read before the pool is touched, so a file the reader cannot use leaves the
+ project as it stands and the history without an entry.
+ """
+ self._session_manager.set_instrument_path(filepath.parent)
+ imported = self._read_instrument(filepath)
+ if imported is None:
+ return
+
+ with self._history.transaction(
+ HistoryAction.ADD_INSTRUMENT,
+ detail=self._history_detail.add_instrument(imported.voice.name),
+ ):
+ self._voices_logic.add_instrument(imported.voice)
+
+ self._report_import(imported)
+
+ def remove(self, voice_id: str) -> None:
+ """Removes a voice, confirming first only when a pattern still references it.
+
+ An unused voice is dropped silently; a referenced one would clear every row
+ that points at it, so the user confirms that loss first.
+ """
+ if not self._voices_logic.is_voice_used(voice_id):
+ self._perform_remove(voice_id)
+ return
+
+ name = self._voices_logic.voice_name(voice_id)
+ self._dialogs.show_confirmation(
+ tag=TAG_SEQUENCER_VOICES_DIALOG_REMOVE,
+ title=self._language_manager["global.dialog.title.remove_voice"],
+ message=self._language_manager["global.dialog.message.remove_voice"].format(name=name),
+ on_confirm=lambda: self._perform_remove(voice_id),
+ ok_label=self._language_manager["global.dialog.label.remove"],
+ )
+
+ def submit_rename(self, voice_id: str, name: str) -> None:
+ """Applies an inline rename, ignoring a blank name so the voice keeps its current one."""
+ stripped = name.strip()
+ if stripped:
+ detail = self._history_detail.rename_voice(voice_id, stripped)
+ with self._history.transaction(
+ HistoryAction.RENAME_VOICE,
+ detail=detail,
+ ):
+ self._voices_logic.rename_voice(voice_id, stripped)
+
+ def _perform_remove(self, voice_id: str) -> None:
+ detail = self._history_detail.remove_voice(voice_id)
+ with self._history.transaction(
+ HistoryAction.REMOVE_VOICE,
+ detail=detail,
+ ):
+ self._voices_logic.remove_voice(voice_id)
+
+ def _read_instrument(self, filepath: Path) -> Optional[ImportedVoice]:
+ """Reads an instrument file, reporting a file the reader cannot take as a voice.
+
+ Returns:
+ Optional[ImportedVoice]: The voice the file describes, or ``None`` once the failure
+ has been shown.
+ """
+ try:
+ return self._voices_logic.read_instrument(filepath)
+ except FileNotFoundError as exception:
+ logger.error_with_traceback(exception, f"No instrument file at {filepath}")
+ self._dialogs.show_file_not_found(
+ filepath,
+ self._language_manager["sequencer.voices.message.instrument_not_found"],
+ )
+ except (LoadInstrumentError, OSError) as exception:
+ logger.error_with_traceback(exception, f"Failed to read an instrument from {filepath}")
+ self._dialogs.show_error(exception)
+
+ return None
+
+ def _report_import(self, imported: ImportedVoice) -> None:
+ """Names what the instrument file carried beyond the voice the pool took from it."""
+ notice = self._import_messages.notice(imported.voice.name, imported.omissions)
+ if notice is not None:
+ self._dialogs.show_info(
+ TAG_GLOBAL_DIALOG_INSTRUMENT_IMPORTED,
+ notice,
+ self._import_messages.title,
+ )
diff --git a/src/sampletones_application/exports.py b/src/sampletones_application/exports.py
new file mode 100644
index 000000000..f36e433d9
--- /dev/null
+++ b/src/sampletones_application/exports.py
@@ -0,0 +1,23 @@
+from typing import Dict
+
+from sampletones_core.exports.backend import ExportBackend
+from sampletones_core.exports.format import ExportFormat
+from sampletones_core.exports.registry import build_tracker_backends
+from sampletones_player.export import NSFBackend
+
+
+def build_export_backends() -> Dict[ExportFormat, ExportBackend]:
+ """Builds every backend the application exports through.
+
+ The reconstruction engine stands below the console player and owns the tracker formats
+ alone, so the backend writing a program the console runs joins them here, where both
+ packages are in reach. Everything the application offers to export in is keyed by its
+ format in the result, so a menu entry, a file type and a shortcut all reach one backend.
+
+ Returns:
+ Dict[ExportFormat, ExportBackend]: Every backend, keyed by the format it writes.
+ """
+ return {
+ **build_tracker_backends(),
+ ExportFormat.NSF: NSFBackend(),
+ }
diff --git a/src/sampletones_application/layout/general/__init__.py b/src/sampletones_application/layout/general/__init__.py
index 4c793068d..1c09e6923 100644
--- a/src/sampletones_application/layout/general/__init__.py
+++ b/src/sampletones_application/layout/general/__init__.py
@@ -12,6 +12,7 @@
from sampletones_application.layout.general.responsive import ResponsiveLayout
from sampletones_application.layout.general.section_header import SectionHeaderLayout
from sampletones_application.layout.general.status_bar import StatusBarLayout
+from sampletones_application.layout.general.stems import StemsListLayout
from sampletones_application.layout.general.tables import TablesLayout
from sampletones_application.layout.general.window import WindowLayout
@@ -26,6 +27,7 @@ class GeneralLayout(BaseModel, extra="forbid", frozen=True):
inputs: InputsLayout
buttons: ButtonsLayout
tables: TablesLayout
+ stems: StemsListLayout
pitch_stepper: PitchStepperLayout
plus_minus_buttons: PlusMinusButtonsLayout
caret: CaretLayout
diff --git a/src/sampletones_application/layout/general/colors/channel.py b/src/sampletones_application/layout/general/colors/channel.py
new file mode 100644
index 000000000..9da960cb9
--- /dev/null
+++ b/src/sampletones_application/layout/general/colors/channel.py
@@ -0,0 +1,30 @@
+from pydantic import BaseModel
+
+from sampletones_application.utils.palette.colors.written import WrittenColor
+from sampletones_core.constants.enums import ChannelName
+
+
+class ChannelColors(BaseModel, extra="forbid", frozen=True):
+ """The per-channel palette shared by every view that names a channel.
+
+ The order table paints each channel's row label in its color, the tracker grid tints each
+ channel's column background with the same color at a low alpha, and the waveform plots draw a
+ generator's line in it, so a channel keeps one identity across the application.
+ """
+
+ pulse1: WrittenColor
+ pulse2: WrittenColor
+ triangle: WrittenColor
+ noise: WrittenColor
+
+ def for_channel(self, channel_name: ChannelName) -> WrittenColor:
+ """The color this channel is known by."""
+ match channel_name:
+ case ChannelName.PULSE1:
+ return self.pulse1
+ case ChannelName.PULSE2:
+ return self.pulse2
+ case ChannelName.TRIANGLE:
+ return self.triangle
+ case ChannelName.NOISE:
+ return self.noise
diff --git a/src/sampletones_application/layout/general/colors/colors.py b/src/sampletones_application/layout/general/colors/colors.py
index 41208d3a7..83e904df4 100644
--- a/src/sampletones_application/layout/general/colors/colors.py
+++ b/src/sampletones_application/layout/general/colors/colors.py
@@ -1,5 +1,6 @@
from pydantic import BaseModel
+from sampletones_application.layout.general.colors.channel import ChannelColors
from sampletones_application.layout.general.colors.favorite import FavoriteColors
from sampletones_application.layout.general.colors.feature import FeatureColors
from sampletones_application.layout.general.colors.header import HeaderColors
@@ -15,3 +16,4 @@ class GeneralColors(BaseModel, extra="forbid", frozen=True):
paths: PathColors
headers: HeaderColors
features: FeatureColors
+ channels: ChannelColors
diff --git a/src/sampletones_application/layout/general/colors/feature.py b/src/sampletones_application/layout/general/colors/feature.py
index e8780ee55..32aac5b3a 100644
--- a/src/sampletones_application/layout/general/colors/feature.py
+++ b/src/sampletones_application/layout/general/colors/feature.py
@@ -7,11 +7,12 @@ class FeatureColors(BaseModel, extra="forbid", frozen=True):
"""The per-feature palette shared by every view that names a feature.
The details tab's bar plots and the history panel's detail segments both
- paint from this block, so a feature keeps one colour across the
+ paint from this block, so a feature keeps one color across the
application.
"""
volume: WrittenColor
arpeggio: WrittenColor
pitch: WrittenColor
+ hi_pitch: WrittenColor
duty_cycle: WrittenColor
diff --git a/src/sampletones_application/layout/general/columns.py b/src/sampletones_application/layout/general/columns.py
index 6912944f4..3f85fb18a 100644
--- a/src/sampletones_application/layout/general/columns.py
+++ b/src/sampletones_application/layout/general/columns.py
@@ -10,7 +10,7 @@ class ColumnsLayout(BaseModel, extra="forbid", frozen=True):
that every tab carries, so the side panel stays the same size across tabs;
each tab's own right column lives in that tab's section (``.right_column``).
A column ``height`` of -1 fills the tab vertically. ``center_weight`` is the
- share of the surplus width the stretching centre column claims against each side
+ share of the surplus width the stretching center column claims against each side
column's single share as the viewport grows past the responsive baseline (see
``ResponsiveLayout`` and ``expanded_side_width``).
"""
diff --git a/src/sampletones_application/layout/general/stems.py b/src/sampletones_application/layout/general/stems.py
new file mode 100644
index 000000000..46fa060ff
--- /dev/null
+++ b/src/sampletones_application/layout/general/stems.py
@@ -0,0 +1,10 @@
+from pydantic import BaseModel
+
+
+class StemsListLayout(BaseModel, extra="forbid", frozen=True):
+ master_column_width: int
+ channel_column_width: int
+ remove_button_width: int
+ level_strip_height: int
+ well_padding: int
+ well_margin: int
diff --git a/src/sampletones_application/layout/glyphs/glyphs.py b/src/sampletones_application/layout/glyphs/glyphs.py
index f54ee443f..0083f2fab 100644
--- a/src/sampletones_application/layout/glyphs/glyphs.py
+++ b/src/sampletones_application/layout/glyphs/glyphs.py
@@ -3,9 +3,11 @@
from sampletones_application.layout.glyphs.common import CommonGlyphs
from sampletones_application.layout.glyphs.header import HeaderGlyphs
from sampletones_application.layout.glyphs.player import PlayerGlyphs
+from sampletones_application.layout.glyphs.voice import VoiceGlyphs
class Glyphs(BaseModel, extra="forbid", frozen=True):
common: CommonGlyphs
headers: HeaderGlyphs
player: PlayerGlyphs
+ voices: VoiceGlyphs
diff --git a/src/sampletones_application/layout/glyphs/header.py b/src/sampletones_application/layout/glyphs/header.py
index 660fbbbd9..a08092c03 100644
--- a/src/sampletones_application/layout/glyphs/header.py
+++ b/src/sampletones_application/layout/glyphs/header.py
@@ -14,7 +14,7 @@ class HeaderGlyphs(BaseModel, extra="forbid", frozen=True):
parameters: str
source: str
instruments: str
- samples: str
+ voices: str
tracker: str
order: str
history: str
diff --git a/src/sampletones_application/layout/glyphs/voice.py b/src/sampletones_application/layout/glyphs/voice.py
new file mode 100644
index 000000000..c60dbd32b
--- /dev/null
+++ b/src/sampletones_application/layout/glyphs/voice.py
@@ -0,0 +1,6 @@
+from pydantic import BaseModel
+
+
+class VoiceGlyphs(BaseModel, extra="forbid", frozen=True):
+ sample: str
+ instrument: str
diff --git a/src/sampletones_application/layout/graphs/spectrum.py b/src/sampletones_application/layout/graphs/spectrum.py
index 6757fdf39..5c4ae48a0 100644
--- a/src/sampletones_application/layout/graphs/spectrum.py
+++ b/src/sampletones_application/layout/graphs/spectrum.py
@@ -6,4 +6,3 @@
class SpectrumLayout(BaseModel, extra="forbid", frozen=True):
max_display_bins: int
color_dim: WrittenColor
- color_bright: WrittenColor
diff --git a/src/sampletones_application/layout/settings/__init__.py b/src/sampletones_application/layout/settings/__init__.py
index 99c11b68b..f95124389 100644
--- a/src/sampletones_application/layout/settings/__init__.py
+++ b/src/sampletones_application/layout/settings/__init__.py
@@ -2,6 +2,7 @@
from sampletones_application.layout.settings.audio import AudioSettingsLayout
from sampletones_application.layout.settings.display import DisplaySettingsLayout
+from sampletones_application.layout.settings.export import ExportSettingsLayout
from sampletones_application.layout.settings.keybindings import KeybindingsSettingsLayout
from sampletones_application.layout.settings.render import RenderSettingsLayout
@@ -19,3 +20,4 @@ class SettingsLayout(BaseModel, extra="forbid", frozen=True):
display: DisplaySettingsLayout
keybindings: KeybindingsSettingsLayout
render: RenderSettingsLayout
+ export: ExportSettingsLayout
diff --git a/src/sampletones_application/layout/settings/export.py b/src/sampletones_application/layout/settings/export.py
new file mode 100644
index 000000000..5c4d289a9
--- /dev/null
+++ b/src/sampletones_application/layout/settings/export.py
@@ -0,0 +1,7 @@
+from pydantic import BaseModel
+
+from sampletones_application.layout.primitives import Dimensions
+
+
+class ExportSettingsLayout(BaseModel, extra="forbid", frozen=True):
+ window: Dimensions
diff --git a/src/sampletones_application/layout/tabs/main/converter.py b/src/sampletones_application/layout/tabs/main/converter.py
index 64dc2a5ba..003338189 100644
--- a/src/sampletones_application/layout/tabs/main/converter.py
+++ b/src/sampletones_application/layout/tabs/main/converter.py
@@ -1,7 +1,10 @@
from pydantic import BaseModel
+from sampletones_application.layout.primitives import Dimensions
+
class ConverterLayout(BaseModel, extra="forbid", frozen=True):
width: int
- height: int
button_height: int
+ stem_selection: Dimensions
+ stem_selection_footer: int
diff --git a/src/sampletones_application/layout/tabs/sequencer/colors/channel.py b/src/sampletones_application/layout/tabs/sequencer/colors/channel.py
deleted file mode 100644
index c43e3655e..000000000
--- a/src/sampletones_application/layout/tabs/sequencer/colors/channel.py
+++ /dev/null
@@ -1,17 +0,0 @@
-from pydantic import BaseModel
-
-from sampletones_application.utils.palette.colors.written import WrittenColor
-
-
-class ChannelColors(BaseModel, extra="forbid", frozen=True):
- """Per-channel identity colours shared by the order table and the tracker grid.
-
- The order table paints each channel's row label in its colour; the tracker grid
- tints each channel's column background with the same colour at a low alpha, so a
- channel keeps one identity across both views.
- """
-
- pulse1: WrittenColor
- pulse2: WrittenColor
- triangle: WrittenColor
- noise: WrittenColor
diff --git a/src/sampletones_application/layout/tabs/sequencer/colors/colors.py b/src/sampletones_application/layout/tabs/sequencer/colors/colors.py
index ba05e75e8..39b4273bb 100644
--- a/src/sampletones_application/layout/tabs/sequencer/colors/colors.py
+++ b/src/sampletones_application/layout/tabs/sequencer/colors/colors.py
@@ -1,6 +1,5 @@
from pydantic import BaseModel
-from sampletones_application.layout.tabs.sequencer.colors.channel import ChannelColors
from sampletones_application.layout.tabs.sequencer.colors.header import HeaderColors
from sampletones_application.layout.tabs.sequencer.colors.history import HistoryColors
from sampletones_application.layout.tabs.sequencer.colors.muted import MutedColors
@@ -24,4 +23,3 @@ class SequencerColors(BaseModel, extra="forbid", frozen=True):
muted: MutedColors
history: HistoryColors
text: TrackerColors
- channels: ChannelColors
diff --git a/src/sampletones_application/layout/tabs/sequencer/colors/header.py b/src/sampletones_application/layout/tabs/sequencer/colors/header.py
index 21dce042d..bd1325612 100644
--- a/src/sampletones_application/layout/tabs/sequencer/colors/header.py
+++ b/src/sampletones_application/layout/tabs/sequencer/colors/header.py
@@ -4,7 +4,7 @@
class HeaderColors(BaseModel, extra="forbid", frozen=True):
- """Colours the tracker's clickable column header takes.
+ """Colors the tracker's clickable column header takes.
``background`` is the band the header row sits in, the shade a table header carries;
``hovered`` and ``active`` are the washes a header label takes under the pointer and while
diff --git a/src/sampletones_application/layout/tabs/sequencer/colors/history.py b/src/sampletones_application/layout/tabs/sequencer/colors/history.py
index 837c60ac5..ac07736e3 100644
--- a/src/sampletones_application/layout/tabs/sequencer/colors/history.py
+++ b/src/sampletones_application/layout/tabs/sequencer/colors/history.py
@@ -5,7 +5,7 @@
class HistoryColors(BaseModel, extra="forbid", frozen=True):
- """Colours for the history detail: the dimmed tint of future (redoable) entries
+ """Colors for the history detail: the dimmed tint of future (redoable) entries
and the per-role token palette.
"""
diff --git a/src/sampletones_application/layout/tabs/sequencer/colors/history_role.py b/src/sampletones_application/layout/tabs/sequencer/colors/history_role.py
index ee02d51fd..f4a8eb7c8 100644
--- a/src/sampletones_application/layout/tabs/sequencer/colors/history_role.py
+++ b/src/sampletones_application/layout/tabs/sequencer/colors/history_role.py
@@ -4,9 +4,9 @@
class HistoryRoleColors(BaseModel, extra="forbid", frozen=True):
- """Colours for the history-detail token roles unique to the detail line.
+ """Colors for the history-detail token roles unique to the detail line.
- The instrument/transpose/volume, frame, row, and sample tokens draw from the
+ The voice/transpose/volume, frame, row, and sample tokens draw from the
shared :class:`TrackerColors` palette; only the roles unique to the detail line
live here.
"""
diff --git a/src/sampletones_application/layout/tabs/sequencer/colors/muted.py b/src/sampletones_application/layout/tabs/sequencer/colors/muted.py
index 7e430b1a5..ea47917ea 100644
--- a/src/sampletones_application/layout/tabs/sequencer/colors/muted.py
+++ b/src/sampletones_application/layout/tabs/sequencer/colors/muted.py
@@ -4,7 +4,7 @@
class MutedColors(BaseModel, extra="forbid", frozen=True):
- """Colours marking a channel the song player silences.
+ """Colors marking a channel the song player silences.
``background`` is the neutral shade the channel takes in place of its identity tint —
down its column in the tracker, along its row in the order table — so the channel
diff --git a/src/sampletones_application/layout/tabs/sequencer/colors/order.py b/src/sampletones_application/layout/tabs/sequencer/colors/order.py
index ffd0eed4b..b6b691d2d 100644
--- a/src/sampletones_application/layout/tabs/sequencer/colors/order.py
+++ b/src/sampletones_application/layout/tabs/sequencer/colors/order.py
@@ -4,7 +4,7 @@
class OrderColors(BaseModel, extra="forbid", frozen=True):
- """Colours specific to the order table: the row-label column, the master row and
+ """Colors specific to the order table: the row-label column, the master row and
the divider below it, and the per-column highlights for the current and playing
positions.
"""
diff --git a/src/sampletones_application/layout/tabs/sequencer/colors/row.py b/src/sampletones_application/layout/tabs/sequencer/colors/row.py
index 559bc8001..2660cf950 100644
--- a/src/sampletones_application/layout/tabs/sequencer/colors/row.py
+++ b/src/sampletones_application/layout/tabs/sequencer/colors/row.py
@@ -4,7 +4,7 @@
class RowColors(BaseModel, extra="forbid", frozen=True):
- """Colours marking where a tracker row falls in the pulse of the pattern.
+ """Colors marking where a tracker row falls in the pulse of the pattern.
``beat`` lifts the row that opens each beat off the zebra stripe and ``bar`` marks the
row that opens each bar more strongly, so a long pattern reads as a rhythm at a glance.
diff --git a/src/sampletones_application/layout/tabs/sequencer/colors/sample.py b/src/sampletones_application/layout/tabs/sequencer/colors/sample.py
index 02ccae710..e10518bf2 100644
--- a/src/sampletones_application/layout/tabs/sequencer/colors/sample.py
+++ b/src/sampletones_application/layout/tabs/sequencer/colors/sample.py
@@ -4,7 +4,7 @@
class SampleColors(BaseModel, extra="forbid", frozen=True):
- """Colours marking the tracker's sample column and the divider beside it."""
+ """Colors marking the tracker's sample column and the divider beside it."""
column: WrittenColor
divider: WrittenColor
diff --git a/src/sampletones_application/layout/tabs/sequencer/colors/tracker.py b/src/sampletones_application/layout/tabs/sequencer/colors/tracker.py
index b6882e867..c297d3682 100644
--- a/src/sampletones_application/layout/tabs/sequencer/colors/tracker.py
+++ b/src/sampletones_application/layout/tabs/sequencer/colors/tracker.py
@@ -4,19 +4,22 @@
class TrackerColors(BaseModel, extra="forbid", frozen=True):
- """The semantic text colours shared across every tracker view.
+ """The semantic text colors shared across every tracker view.
One palette feeds the pattern grid, the order table, and the history detail so a
- concept keeps its colour everywhere: ``instrument`` (the note/sample reference,
- yellow like ``sample``), ``transpose``, ``volume``, ``sample``, the ``frame`` and
- ``row`` indices, and the ``order`` entries. Defining them once keeps every panel in
- step.
+ concept keeps its color everywhere. Three of the tokens read a voice slot: ``voice``
+ is what the slot wears while it names nothing, and ``sample`` and ``instrument`` are
+ the two kinds a named voice can be, so the slot's color reports what it holds.
+ ``transpose`` and ``volume`` carry the other two slots, and the ``frame`` and ``row``
+ indices and the ``order`` entries carry the grids around them. Defining them once
+ keeps every panel in step.
"""
- instrument: WrittenColor
+ voice: WrittenColor
transpose: WrittenColor
volume: WrittenColor
sample: WrittenColor
+ instrument: WrittenColor
frame: WrittenColor
row: WrittenColor
order: WrittenColor
diff --git a/src/sampletones_application/layout/tabs/sequencer/tables/cells.py b/src/sampletones_application/layout/tabs/sequencer/tables/cells.py
index 8cc234eb3..244f8b250 100644
--- a/src/sampletones_application/layout/tabs/sequencer/tables/cells.py
+++ b/src/sampletones_application/layout/tabs/sequencer/tables/cells.py
@@ -1,11 +1,11 @@
from pydantic import BaseModel
-from sampletones_application.layout.tabs.sequencer.tables.instrument import InstrumentColumnWidths
+from sampletones_application.layout.tabs.sequencer.tables.voice import VoiceColumnWidths
class SequencerTableCells(BaseModel, extra="forbid", frozen=True):
row: int
sample: int
divider: int
- generator: int
- instrument: InstrumentColumnWidths
+ channel: int
+ voice: VoiceColumnWidths
diff --git a/src/sampletones_application/layout/tabs/sequencer/tables/instrument.py b/src/sampletones_application/layout/tabs/sequencer/tables/instrument.py
deleted file mode 100644
index 83ac368b2..000000000
--- a/src/sampletones_application/layout/tabs/sequencer/tables/instrument.py
+++ /dev/null
@@ -1,11 +0,0 @@
-from pydantic import BaseModel
-
-
-class InstrumentColumnWidths(BaseModel, extra="forbid", frozen=True):
- """Widths of the three sub-columns that make up an instrument row: its id, its
- name, and its loop marker. They only mean anything as a set, so they live together.
- """
-
- id: int
- name: int
- loop: int
diff --git a/src/sampletones_application/layout/tabs/sequencer/tables/voice.py b/src/sampletones_application/layout/tabs/sequencer/tables/voice.py
new file mode 100644
index 000000000..cfefb44fb
--- /dev/null
+++ b/src/sampletones_application/layout/tabs/sequencer/tables/voice.py
@@ -0,0 +1,11 @@
+from pydantic import BaseModel
+
+
+class VoiceColumnWidths(BaseModel, extra="forbid", frozen=True):
+ """Widths of the three sub-columns that make up a voice row: its kind mark, its id and
+ its name. They only mean anything as a set, so they live together.
+ """
+
+ kind: int
+ id: int
+ name: int
diff --git a/src/sampletones_application/layout/tabs/sequencer/tracker/subcolumn.py b/src/sampletones_application/layout/tabs/sequencer/tracker/subcolumn.py
index 67ff0740f..2e744d054 100644
--- a/src/sampletones_application/layout/tabs/sequencer/tracker/subcolumn.py
+++ b/src/sampletones_application/layout/tabs/sequencer/tracker/subcolumn.py
@@ -2,6 +2,6 @@
class SubcolumnWidths(BaseModel, extra="forbid", frozen=True):
- instrument: int
+ voice: int
transpose: int
volume: int
diff --git a/src/sampletones_application/layout/tabs/sequencer/tracker/tracker.py b/src/sampletones_application/layout/tabs/sequencer/tracker/tracker.py
index 07c908cd5..46c6f3794 100644
--- a/src/sampletones_application/layout/tabs/sequencer/tracker/tracker.py
+++ b/src/sampletones_application/layout/tabs/sequencer/tracker/tracker.py
@@ -6,7 +6,7 @@
class TrackerLayout(BaseModel, extra="forbid", frozen=True):
"""The tracker's row counts, cell sizes and tint strengths.
- The grouping the rows are tinted by is the project's own metre, read from its highlights,
+ The grouping the rows are tinted by is the project's own meter, read from its highlights,
so this model carries the geometry alone.
A row states its height rather than growing to the text in it, because the grid's tints are
@@ -19,5 +19,6 @@ class TrackerLayout(BaseModel, extra="forbid", frozen=True):
row_height: int
header_height: int
subcolumn_widths: SubcolumnWidths
+ octave_width: int
channel_column_tint: float
muted_text_fraction: float
diff --git a/src/sampletones_application/logic/export/__init__.py b/src/sampletones_application/logic/export/__init__.py
new file mode 100644
index 000000000..eedfdff7e
--- /dev/null
+++ b/src/sampletones_application/logic/export/__init__.py
@@ -0,0 +1,3 @@
+from .logic import SongExportLogic
+
+__all__ = ["SongExportLogic"]
diff --git a/src/sampletones_core/trackers/implementation/__init__.py b/src/sampletones_application/logic/export/instrument/__init__.py
similarity index 100%
rename from src/sampletones_core/trackers/implementation/__init__.py
rename to src/sampletones_application/logic/export/instrument/__init__.py
diff --git a/src/sampletones_application/logic/export/instrument/logic.py b/src/sampletones_application/logic/export/instrument/logic.py
new file mode 100644
index 000000000..f03624018
--- /dev/null
+++ b/src/sampletones_application/logic/export/instrument/logic.py
@@ -0,0 +1,125 @@
+from pathlib import Path
+from typing import Dict, Mapping, Optional, Tuple
+
+from sampletones_application.config.managers.session import SessionManager
+from sampletones_application.logic.export.instrument.protocol import (
+ InstrumentExportServiceProtocol,
+)
+from sampletones_application.logic.export.instrument.source import (
+ ExportableInstrument,
+ voice_instrument,
+ voice_instrument_channels,
+)
+from sampletones_application.logic.project.controller import ProjectController
+from sampletones_core.constants.enums import ChannelName
+from sampletones_core.exports.backend import ExportBackend
+from sampletones_core.exports.extensions import format_for_extension
+from sampletones_core.exports.format import ExportFormat
+from sampletones_core.exports.request import InstrumentSource
+from sampletones_core.exports.scope import ExportScope
+
+
+class InstrumentExportLogic:
+ """The one way an instrument reaches a file, whichever surface asked for one.
+
+ An instrument export is a set of envelopes, the channel they are read for and the rate they
+ advance at. What produced them — a channel of the open reconstruction, a channel of a project
+ sample, or a voice written by hand — is settled before anything here, so every surface answers
+ with an :class:`InstrumentSource` and reaches the same write.
+
+ A voice in the pool is answered for here as well, so a menu asks what one offers and a click
+ asks for one of them by the channel it names, and both the sequencer's voice menu and the
+ Reconstructions tab's export button write the same file for the same voice.
+
+ The destination carries the last two decisions: its extension names the format, and its stem
+ names the instrument the file holds, so renaming a file in the save dialog renames what is
+ written into it.
+ """
+
+ def __init__(
+ self,
+ project_controller: ProjectController,
+ session_manager: SessionManager,
+ export_service: InstrumentExportServiceProtocol,
+ export_backends: Dict[ExportFormat, ExportBackend],
+ ) -> None:
+ self._controller = project_controller
+ self._session_manager = session_manager
+ self._export_service = export_service
+ self._export_backends = export_backends
+
+ @property
+ def backends(self) -> Mapping[ExportFormat, ExportBackend]:
+ """Every backend an instrument can be written through, keyed by its format."""
+ return self._export_backends
+
+ @property
+ def suggested_directory(self) -> Path:
+ """The folder the save dialog opens on, which is where the last instrument landed."""
+ return self._session_manager.get_instrument_path()
+
+ def voice_instruments(self, voice_id: str) -> Tuple[Optional[ChannelName], ...]:
+ """What one voice offers to an export, each named by the channel it is stated for.
+
+ A menu reads this to decide whether it offers an item or a choice of channels, and hands
+ one entry back to :meth:`voice_instrument`.
+
+ Args:
+ voice_id: The voice whose instruments are offered.
+
+ Returns:
+ Tuple[Optional[ChannelName], ...]: One entry per instrument the voice holds, empty
+ while the pool holds no such voice or the voice writes nothing.
+ """
+ return voice_instrument_channels(self._controller.project, voice_id)
+
+ def voice_instrument(
+ self,
+ voice_id: str,
+ channel_name: Optional[ChannelName],
+ ) -> Optional[ExportableInstrument]:
+ """One of a voice's instruments, ready to be given a destination.
+
+ Args:
+ voice_id: The voice the instrument belongs to.
+ channel_name: The channel the instrument is stated for, as
+ :meth:`voice_instruments` named it.
+
+ Returns:
+ Optional[ExportableInstrument]: The instrument and the name to suggest for it, or
+ ``None`` where the voice holds no instrument answering to that channel.
+
+ Raises:
+ ValueError: If the project's samples were reconstructed against tunings that differ.
+ """
+ return voice_instrument(self._controller.project, voice_id, channel_name)
+
+ def export(self, destination: Path, source: InstrumentSource) -> None:
+ """Writes one instrument to ``destination``, in the format its extension names.
+
+ Args:
+ destination: The file the save dialog was confirmed with.
+ source: The instrument to write, awaiting the name the destination gives it.
+
+ Raises:
+ ValueError: If no format writing a single instrument claims the destination's
+ extension, which a dialog offering those formats alone never yields.
+ """
+ export_format = self._format(destination)
+ self._session_manager.set_instrument_path(destination.parent)
+ self._export_service.export_instrument(
+ destination,
+ self._export_backends[export_format],
+ source.named(destination.stem),
+ )
+
+ def _format(self, destination: Path) -> ExportFormat:
+ export_format = format_for_extension(
+ self._export_backends,
+ ExportScope.INSTRUMENT,
+ destination.suffix,
+ )
+ if export_format is None:
+ raise ValueError(f"No export format writes '{destination.suffix}' for an instrument export")
+
+ return export_format
diff --git a/src/sampletones_application/logic/export/instrument/protocol.py b/src/sampletones_application/logic/export/instrument/protocol.py
new file mode 100644
index 000000000..e417f6ea5
--- /dev/null
+++ b/src/sampletones_application/logic/export/instrument/protocol.py
@@ -0,0 +1,20 @@
+from pathlib import Path
+from typing import Protocol
+
+from sampletones_core.exports.backend import ExportBackend
+from sampletones_core.exports.request import InstrumentExport
+
+
+class InstrumentExportServiceProtocol(Protocol):
+ """The slice of the export service one instrument's export drives.
+
+ Typing the collaborator structurally keeps the logic layer independent of the service
+ implementation; the composition root supplies the real service.
+ """
+
+ def export_instrument(
+ self,
+ destination: Path,
+ backend: ExportBackend,
+ request: InstrumentExport,
+ ) -> None: ...
diff --git a/src/sampletones_application/logic/export/instrument/source.py b/src/sampletones_application/logic/export/instrument/source.py
new file mode 100644
index 000000000..5c710914b
--- /dev/null
+++ b/src/sampletones_application/logic/export/instrument/source.py
@@ -0,0 +1,174 @@
+from dataclasses import dataclass
+from typing import Optional, Tuple
+
+from sampletones_application.constants.instruments import INSTRUMENT_CHANNEL
+from sampletones_core.constants.enums import ChannelName
+from sampletones_core.exporters.slices import (
+ FIRST_INSTRUMENT_INDEX,
+ InstrumentEntry,
+ voice_instrument_entries,
+)
+from sampletones_core.exports.request import InstrumentSource
+from sampletones_core.project.project import Project
+from sampletones_core.project.tuning import tuning_from_project
+from sampletones_core.project.voices.voice import VoiceUnion
+
+
+@dataclass(frozen=True)
+class ExportableInstrument:
+ """One instrument ready to be asked for a destination.
+
+ Attributes:
+ name: The name the save dialog suggests, which the written instrument keeps unless the
+ reader renames the file.
+ source: The instrument itself, awaiting the name its destination gives it.
+ """
+
+ name: str
+ source: InstrumentSource
+
+
+def voice_entries(voice: VoiceUnion) -> Tuple[InstrumentEntry, ...]:
+ """The instruments one voice offers to an export.
+
+ A module writing every voice and a file writing one read the same rule, so a reader is offered
+ exactly the instruments a module would have held: a sample yields one per channel its
+ reconstruction found frames for, and a voice written by hand yields the single set of envelopes
+ every channel reads.
+
+ Args:
+ voice: The voice whose instruments are offered.
+
+ Returns:
+ Tuple[InstrumentEntry, ...]: Its instruments, in channel order.
+ """
+ return tuple(
+ voice_instrument_entries(voice, start_index=FIRST_INSTRUMENT_INDEX),
+ )
+
+
+def sounding_channel(entry: InstrumentEntry) -> ChannelName:
+ """The channel a file holding one instrument alone sounds it on.
+
+ A sample's slice states the channel it was reconstructed for, and that is the channel it is
+ sounded on. A voice written by hand states one set of envelopes for no channel in particular,
+ and a file must still play it somewhere, so it is sounded where the app reads it: the channel
+ its own editor shows it under, whose reference is the tonal root its envelopes are measured
+ against.
+
+ Args:
+ entry: The instrument being written.
+
+ Returns:
+ ChannelName: The channel the written file plays the instrument through.
+ """
+ if entry.channel is None:
+ return INSTRUMENT_CHANNEL
+
+ return entry.channel
+
+
+def instrument_source(
+ project: Project,
+ entry: InstrumentEntry,
+) -> InstrumentSource:
+ """One of a voice's instruments, measured at the rate and tuning its project plays it at.
+
+ Args:
+ project: The project the voice belongs to, which states the rate and the tuning.
+ entry: The instrument being written.
+
+ Returns:
+ InstrumentSource: The instrument, awaiting the name its destination gives it.
+
+ Raises:
+ ValueError: If the project's samples were reconstructed against tunings that differ.
+ """
+ return InstrumentSource(
+ channel=sounding_channel(entry),
+ features=entry.features,
+ nes_frequency=project.settings.nes_frequency,
+ tuning=tuning_from_project(project),
+ )
+
+
+def exportable_instrument(
+ project: Project,
+ entry: InstrumentEntry,
+) -> ExportableInstrument:
+ """One of a voice's instruments, ready to be given a destination.
+
+ Args:
+ project: The project the voice belongs to, which states the rate and the tuning.
+ entry: The instrument being written.
+
+ Returns:
+ ExportableInstrument: The instrument and the name to suggest for it.
+
+ Raises:
+ ValueError: If the project's samples were reconstructed against tunings that differ.
+ """
+ return ExportableInstrument(
+ name=entry.name,
+ source=instrument_source(project, entry),
+ )
+
+
+def voice_instrument_channels(
+ project: Project,
+ voice_id: str,
+) -> Tuple[Optional[ChannelName], ...]:
+ """What one project voice offers to an export, each named by the channel it is stated for.
+
+ A sample answers with the channels its reconstruction found frames for; a voice written by hand
+ answers with a single ``None``, since its one set of envelopes belongs to no channel in
+ particular.
+
+ Args:
+ project: The project the voice belongs to.
+ voice_id: The voice whose instruments are offered.
+
+ Returns:
+ Tuple[Optional[ChannelName], ...]: One entry per instrument the voice holds, empty while
+ the pool holds no such voice or the voice writes nothing.
+ """
+ voice = project.voices.get(voice_id)
+ if voice is None:
+ return ()
+
+ return tuple(entry.channel for entry in voice_entries(voice))
+
+
+def voice_instrument(
+ project: Project,
+ voice_id: str,
+ channel_name: Optional[ChannelName],
+) -> Optional[ExportableInstrument]:
+ """One of a project voice's instruments, named by the channel it is stated for.
+
+ Args:
+ project: The project the voice belongs to.
+ voice_id: The voice the instrument belongs to.
+ channel_name: The channel the instrument is stated for, ``None`` where the voice holds the
+ one set of envelopes every channel reads.
+
+ Returns:
+ Optional[ExportableInstrument]: The instrument and the name to suggest for it, or ``None``
+ where the pool holds no such voice or the voice holds no instrument answering to that
+ channel.
+
+ Raises:
+ ValueError: If the project's samples were reconstructed against tunings that differ.
+ """
+ voice = project.voices.get(voice_id)
+ if voice is None:
+ return None
+
+ entry = next(
+ (candidate for candidate in voice_entries(voice) if candidate.channel == channel_name),
+ None,
+ )
+ if entry is None:
+ return None
+
+ return exportable_instrument(project, entry)
diff --git a/src/sampletones_application/logic/export/logic.py b/src/sampletones_application/logic/export/logic.py
new file mode 100644
index 000000000..3da132ced
--- /dev/null
+++ b/src/sampletones_application/logic/export/logic.py
@@ -0,0 +1,158 @@
+from typing import Callable, Dict, Final, List, Optional
+
+from sampletones_application.services.export.result import (
+ ExportError,
+ ExportResult,
+ ExportSuccess,
+)
+from sampletones_application.services.result import (
+ ServiceCanceled,
+ ServiceProgress,
+ ServiceStarted,
+)
+from sampletones_application.view_model.shared.export import (
+ NO_PROGRESS,
+ NOTHING_MEASURED,
+ ExportPhase,
+ SongExportViewModel,
+)
+from sampletones_core.exports.stage import TRAVELING_STAGES, ExportStage
+from sampletones_shared.types.callback import VoidCallback
+from sampletones_shared.utils.callbacks import CallbackMixin
+
+from .protocol import ExportProgressServiceProtocol
+
+NO_FIGURE: Final[str] = ""
+
+
+class SongExportLogic(CallbackMixin):
+ """Owns what a running export looks like to the reader: which stages it has reached, and
+ where the one under way stands.
+
+ A format states its own stages as it reaches them, so the run's shape is learned rather than
+ declared: a tracker instrument reports one stage and a console program three. What the stage
+ under way has covered is read in the unit that stage counts in — a fraction where it arrives
+ at an end, and the bytes against the room there is where it does not.
+
+ An export holds the application while it writes, so the dialog stands from the first word the
+ run says until the outcome that ends it, and cancelling is answered at the next point the
+ format looks up.
+ """
+
+ def __init__(
+ self,
+ export_service: ExportProgressServiceProtocol,
+ *,
+ stage_labels: Dict[ExportStage, str],
+ size_template: str,
+ cancelling_label: str,
+ ) -> None:
+ self._service = export_service
+ self._stage_labels = stage_labels
+ self._size_template = size_template
+ self._cancelling_label = cancelling_label
+
+ self._phase: ExportPhase = ExportPhase.IDLE
+ self._stages: List[ExportStage] = []
+ self._figure: str = NO_FIGURE
+ self._progress: float = NO_PROGRESS
+ self._travelling: bool = False
+
+ self._service.subscribe(self._on_service_result)
+
+ self.on_view_changed: Optional[Callable[[SongExportViewModel], None]] = None
+ self.on_started: Optional[VoidCallback] = None
+ self.on_finished: Optional[VoidCallback] = None
+
+ @property
+ def is_active(self) -> bool:
+ """An export holds the dialog from its first word until the outcome that ends it."""
+ return self._phase != ExportPhase.IDLE
+
+ def stage_label(self, stage: ExportStage) -> str:
+ """What the reader knows ``stage`` by."""
+ return self._stage_labels[stage]
+
+ def cancel(self) -> None:
+ """Asks a running export to stop at the next point the format looks up."""
+ if not self._service.is_running():
+ return
+
+ self._phase = ExportPhase.CANCELLING
+ self._figure = self._cancelling_label
+ self._travelling = False
+ self._emit_view()
+ self._service.cancel()
+
+ def cleanup(self) -> None:
+ """Winds a running export down for application exit."""
+ self._service.shutdown()
+
+ def _on_service_result(self, result: ExportResult) -> None:
+ match result:
+ case ServiceStarted():
+ self._on_started()
+ case ServiceProgress() as progress:
+ self._on_progress(progress)
+ case ExportSuccess() | ExportError() | ServiceCanceled():
+ self._on_finished()
+
+ def _on_started(self) -> None:
+ self._phase = ExportPhase.EXPORTING
+ self._stages = []
+ self._figure = NO_FIGURE
+ self._progress = NO_PROGRESS
+ self._travelling = True
+ self._emit_view()
+ self.call(self.on_started)
+
+ def _on_progress(self, progress: ServiceProgress[ExportStage]) -> None:
+ """Puts the stage's own reading on screen, holding what a stop was asked under."""
+ if self._phase == ExportPhase.CANCELLING:
+ return
+
+ stage = progress.current_item
+ if stage is None:
+ return
+
+ self._reach(stage)
+ self._travelling = stage in TRAVELING_STAGES
+ self._progress = progress.fraction
+ self._figure = self._figure_text(progress)
+ self._emit_view()
+
+ def _reach(self, stage: ExportStage) -> None:
+ if stage not in self._stages:
+ self._stages.append(stage)
+
+ def _figure_text(self, progress: ServiceProgress[ExportStage]) -> str:
+ """What the stage under way has covered, stated where the stage travels toward no end.
+
+ A stage arriving at an end carries its own bar and the percentage written over it, so the
+ figure is spelled out for the one that does not: what the song takes so far against what
+ the console has room for, which is the answer the reader is waiting on.
+ """
+ if self._travelling or progress.total <= NOTHING_MEASURED:
+ return NO_FIGURE
+
+ return self._size_template.format(
+ completed=progress.completed,
+ total=progress.total,
+ )
+
+ def _on_finished(self) -> None:
+ self._phase = ExportPhase.IDLE
+ self._emit_view()
+ self.call(self.on_finished)
+
+ def _emit_view(self) -> None:
+ self.call(self.on_view_changed, self._view_model())
+
+ def _view_model(self) -> SongExportViewModel:
+ return SongExportViewModel(
+ phase=self._phase,
+ stages=tuple(self._stages),
+ figure=self._figure,
+ progress=self._progress,
+ traveling=self._travelling,
+ )
diff --git a/src/sampletones_application/logic/export/protocol.py b/src/sampletones_application/logic/export/protocol.py
new file mode 100644
index 000000000..cd991128c
--- /dev/null
+++ b/src/sampletones_application/logic/export/protocol.py
@@ -0,0 +1,19 @@
+from typing import Callable, Protocol
+
+from sampletones_application.services.export.result import ExportResult
+
+
+class ExportProgressServiceProtocol(Protocol):
+ """The slice of the export service the progress dialog's logic drives.
+
+ Typing the collaborator structurally keeps the logic layer independent of the service
+ implementation; the composition root supplies the real service.
+ """
+
+ def subscribe(self, handler: Callable[[ExportResult], None]) -> None: ...
+
+ def cancel(self) -> None: ...
+
+ def is_running(self) -> bool: ...
+
+ def shutdown(self) -> None: ...
diff --git a/src/sampletones_application/logic/history/action.py b/src/sampletones_application/logic/history/action.py
index 6a6074744..8bfb66fbd 100644
--- a/src/sampletones_application/logic/history/action.py
+++ b/src/sampletones_application/logic/history/action.py
@@ -26,12 +26,13 @@ class HistoryAction(AbstractElement):
MOVE_FRAME = "move_frame"
SET_ORDER_ENTRY = "set_order_entry"
ADD_SAMPLE = "add_sample"
- REMOVE_SAMPLE = "remove_sample"
+ ADD_INSTRUMENT = "add_instrument"
REPLACE_SAMPLE = "replace_sample"
- RENAME_SAMPLE = "rename_sample"
- MOVE_SAMPLE = "move_sample"
- DUPLICATE_SAMPLE = "duplicate_sample"
- SET_SAMPLE_LOOP = "set_sample_loop"
+ EDIT_INSTRUMENT = "edit_instrument"
+ RENAME_VOICE = "rename_voice"
+ MOVE_VOICE = "move_voice"
+ DUPLICATE_VOICE = "duplicate_voice"
+ REMOVE_VOICE = "remove_voice"
SET_TEMPO = "set_tempo"
SET_SPEED = "set_speed"
SET_NES_FREQUENCY = "set_nes_frequency"
diff --git a/src/sampletones_application/logic/history/errors.py b/src/sampletones_application/logic/history/errors.py
index ae756fadc..09c1d7c5a 100644
--- a/src/sampletones_application/logic/history/errors.py
+++ b/src/sampletones_application/logic/history/errors.py
@@ -2,7 +2,7 @@ class UntrackedMutationError(RuntimeError):
"""Raised when a project mutation fires outside any history transaction.
Under strict deployment the history refuses to guess a grouping for an
- unlabelled mutation and surfaces the completeness gap immediately, so the
+ unlabeled mutation and surfaces the completeness gap immediately, so the
call site can be wrapped in a transaction.
"""
diff --git a/src/sampletones_application/logic/history/fingerprint.py b/src/sampletones_application/logic/history/fingerprint.py
index cea0eba01..c196adab6 100644
--- a/src/sampletones_application/logic/history/fingerprint.py
+++ b/src/sampletones_application/logic/history/fingerprint.py
@@ -2,6 +2,9 @@
from typing import Callable, Dict, Iterable, List, Tuple
from sampletones_core.project import Project
+from sampletones_core.project.voices.instrument import Instrument
+from sampletones_core.project.voices.sample import Sample
+from sampletones_core.project.voices.voice import samples
from sampletones_core.reconstructions import Reconstruction
ReconstructionHash = Callable[[Reconstruction], str]
@@ -25,11 +28,14 @@ def fingerprint_project(
project.settings.model_dump_json(),
project.song.model_dump_json(),
]
- for sample in project.samples:
- parts.append(sample.id)
- parts.append(sample.name)
- parts.append(str(sample.loop))
- parts.append(reconstruction_hash(sample.reconstruction))
+ for voice in project.voices:
+ parts.append(voice.id)
+ parts.append(voice.name)
+ match voice:
+ case Sample():
+ parts.append(reconstruction_hash(voice.reconstruction))
+ case Instrument():
+ parts.append(voice.model_dump_json())
combined = "|".join(parts)
return hashlib.sha256(combined.encode("utf-8")).hexdigest()
@@ -62,5 +68,5 @@ def hash(self, reconstruction: Reconstruction) -> str:
return cached[1]
def prune(self, projects: Iterable[Project]) -> None:
- live = {id(sample.reconstruction) for project in projects for sample in project.samples}
+ live = {id(sample.reconstruction) for project in projects for sample in samples(project.voices)}
self._hashes = {key: value for key, value in self._hashes.items() if key in live}
diff --git a/src/sampletones_application/logic/history/manager.py b/src/sampletones_application/logic/history/manager.py
index 62bb8677c..ef58c7183 100644
--- a/src/sampletones_application/logic/history/manager.py
+++ b/src/sampletones_application/logic/history/manager.py
@@ -76,7 +76,7 @@ def is_restoring(self) -> bool:
Every project transition reaches its handlers through the controller's single
``on_project_replaced`` signal, so a handler that keeps transient session state — a
- listening mute set, an acknowledged prompt — reads this to recognise history
+ listening mute set, an acknowledged prompt — reads this to recognize history
navigation and carry that state across it, while a new, opened, or closed document
starts it fresh.
"""
diff --git a/src/sampletones_application/logic/instruction/library.py b/src/sampletones_application/logic/instruction/library.py
index 23439f211..9aa309338 100644
--- a/src/sampletones_application/logic/instruction/library.py
+++ b/src/sampletones_application/logic/instruction/library.py
@@ -10,11 +10,11 @@
from sampletones_application.view_model.instruction.library import (
LibraryPanelViewModel,
)
-from sampletones_core.constants.enums import LibraryGeneratorName
+from sampletones_core.constants.enums import GeneratorName
from sampletones_core.generators import (
GENERATOR_CLASS_MAP,
+ GENERATOR_TO_CLASS_NAME_MAP,
GENERATOR_TO_INSTRUCTION_MAP,
- LIBRARY_GENERATOR_CLASS_MAP,
)
from sampletones_core.instructions import InstructionUnion
from sampletones_core.library import (
@@ -73,7 +73,7 @@ def __init__(
self.on_apply_library_config: Optional[OnApplyLibraryConfigCallback] = None
self.on_generation_completed: Optional[VoidCallback] = None
self.on_generation_error: Optional[Callable[[Exception], None]] = None
- self.on_generation_cancelled: Optional[VoidCallback] = None
+ self.on_generation_canceled: Optional[VoidCallback] = None
self.on_load_file_not_found: Optional[Callable[[Path, str], None]] = None
self.on_load_error: Optional[Callable[[Exception, str], None]] = None
@@ -85,7 +85,7 @@ def __init__(
on_generation_progress=self._on_generation_progress,
on_generation_completed=self._on_generation_completed,
on_generation_error=self._on_generation_error,
- on_generation_cancelled=self._on_generation_cancelled,
+ on_generation_canceled=self._on_generation_canceled,
)
def configure_lock(
@@ -185,11 +185,11 @@ def load_library_file(self, filepath: Path) -> None:
self.load_library_and_set_current(library_key)
self.update_status()
- def load_generator(self, library_generator_name: LibraryGeneratorName) -> None:
+ def load_generator(self, generator_name: GeneratorName) -> None:
if self._is_locked:
return
- generator_class = GENERATOR_CLASS_MAP[LIBRARY_GENERATOR_CLASS_MAP[library_generator_name]]
+ generator_class = GENERATOR_CLASS_MAP[GENERATOR_TO_CLASS_NAME_MAP[generator_name]]
instruction_class = GENERATOR_TO_INSTRUCTION_MAP[generator_class]
instruction = instruction_class.default_instruction()
self.load_instruction(instruction)
@@ -373,8 +373,8 @@ def _on_generation_progress(
self._emit_view(self._language_manager["instructions.library.message.status_saving"], progress=1.0)
case TaskStatus.FAILED:
self._emit_view(self._language_manager["instructions.library.message.status_generation_failed"])
- case TaskStatus.CANCELLED:
- self._emit_view(self._language_manager["instructions.library.message.status_generation_cancelled"])
+ case TaskStatus.CANCELED:
+ self._emit_view(self._language_manager["instructions.library.message.status_generation_canceled"])
case TaskStatus.RUNNING:
self._update_progress_state(task_progress)
@@ -395,7 +395,7 @@ def _update_progress_state(self, task_progress: TaskProgress) -> None:
eta_string=eta_string
)
- self._emit_view(status_text, progress=task_progress.get_progress())
+ self._emit_view(status_text, progress=task_progress.fraction)
def _on_generation_completed(self) -> None:
self.call(self.on_generation_completed)
@@ -405,8 +405,8 @@ def _on_generation_error(self, exception: Exception) -> None:
self.call(self.on_generation_error, exception)
self._finalize_generation_error()
- def _on_generation_cancelled(self) -> None:
- self.call(self.on_generation_cancelled)
+ def _on_generation_canceled(self) -> None:
+ self.call(self.on_generation_canceled)
self._finalize_generation()
def _finalize_generation(self) -> None:
diff --git a/src/sampletones_application/logic/instruction/library_manager.py b/src/sampletones_application/logic/instruction/library_manager.py
index def2e3973..40df1830a 100644
--- a/src/sampletones_application/logic/instruction/library_manager.py
+++ b/src/sampletones_application/logic/instruction/library_manager.py
@@ -5,7 +5,7 @@
from sampletones_application.config.managers.config import ConfigManager
from sampletones_application.view_model.instruction.data import InstructionPanelData
from sampletones_core.configs import Config
-from sampletones_core.constants.enums import LibraryGeneratorName
+from sampletones_core.constants.enums import GeneratorName
from sampletones_core.fft import Window
from sampletones_core.instructions.types import InstructionUnion
from sampletones_core.library import (
@@ -57,7 +57,7 @@ def __init__(
self.on_generation_progress: Optional[OnGenerationProgressCallback] = None
self.on_generation_progress_extra: Optional[OnGenerationProgressCallback] = None
self.on_generation_error: Optional[OnGenerationErrorCallback] = None
- self.on_generation_cancelled: Optional[VoidCallback] = None
+ self.on_generation_canceled: Optional[VoidCallback] = None
def set_library_directory(self, directory: Path) -> None:
self._library = InstructionLibrary(directory=str(directory))
@@ -191,7 +191,7 @@ def _on_progress(status: TaskStatus, progress: TaskProgress) -> None:
on_start=self.on_generation_start,
on_completed=self._complete_generation,
on_error=self.on_generation_error,
- on_cancelled=self.on_generation_cancelled,
+ on_canceled=self.on_generation_canceled,
on_progress=_on_progress,
)
@@ -248,7 +248,7 @@ def shutdown(self) -> None:
"""Tears the library creator's process pool down synchronously for application exit.
A conversion generates its library first, so this pool is the one still spawning
- workers when a run is cancelled and the window is closed; this blocks until it has
+ workers when a run is canceled and the window is closed; this blocks until it has
stopped so the process reaps its workers before releasing shared resources."""
if self._creator:
self._creator.shutdown()
@@ -295,7 +295,7 @@ def _build_library_node(
return library_node
def _build_generator_nodes(self, parent: TreeNode) -> None:
- for generator_name in LibraryGeneratorName:
+ for generator_name in GeneratorName:
GeneratorNode(
generator_name.value.capitalize(),
generator_name=generator_name,
diff --git a/src/sampletones_application/logic/main/converter.py b/src/sampletones_application/logic/main/converter.py
index edb7361e7..f061adac4 100644
--- a/src/sampletones_application/logic/main/converter.py
+++ b/src/sampletones_application/logic/main/converter.py
@@ -1,13 +1,21 @@
from dataclasses import dataclass
from pathlib import Path
-from typing import Callable, Optional, Protocol
+from typing import Callable, Dict, Final, FrozenSet, Optional, Protocol, Sequence, Tuple
from sampletones_application.categories.manager import LanguageManager
from sampletones_application.config.managers.config import ConfigManager
+from sampletones_application.constants.conversion import MAX_STEM_SOURCES, MIN_CHANNEL_CAP
from sampletones_application.layout.behavior.scheduling.scheduling import SchedulingBehavior
+from sampletones_application.logic.main.stems import (
+ ConversionSetup,
+ StemLevels,
+ StemSource,
+ derive_conversion_setup,
+ effective_channels,
+)
+from sampletones_application.services.conversion.result import ConversionItem, ConversionResult
from sampletones_application.services.result import (
- ConversionResult,
- ServiceCancelled,
+ ServiceCanceled,
ServiceError,
ServiceIntermediate,
ServiceProgress,
@@ -21,25 +29,42 @@
ConversionPhase,
ConverterViewModel,
)
+from sampletones_application.view_model.shared.stems import StemRowViewModel
from sampletones_core.configs import Config
+from sampletones_core.constants.algorithm import DEFAULT_STEMS_HIERARCHY_MODE
+from sampletones_core.constants.enums import ChannelName, HierarchyMode
from sampletones_core.parallelization import ETAEstimator, TaskProgress
-from sampletones_core.reconstructions.converter import get_output_path
+from sampletones_core.reconstructions.converter import (
+ ConversionPlan,
+ DirectoryConversion,
+ GroupConversion,
+ get_output_path,
+ group_output_path,
+)
+from sampletones_core.reconstructions.reconstructor.stems.configs.config import StemsConfig
+from sampletones_core.reconstructions.stage import ReconstructionStage
from sampletones_shared.exceptions import NoFilesToProcessError
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 to_path
+
+SINGLE_JOB: Final[int] = 1
+SYSTEM_PROGRESS_STEPS: Final[int] = 1000
@dataclass(frozen=True)
class ConversionSuccess:
- """The outcome a completed conversion hands to its listener.
+ """The outcome a completed conversion hands to its listener: the reconstructions it wrote.
- Carries what the follow-up load offer needs: whether a single file or a
- directory was converted, and where its reconstruction was written."""
+ One written reconstruction is one the reader can open straight away; several are a batch,
+ which the reader reaches as a folder."""
- is_file: bool
- output_path: Optional[Path]
+ written: Tuple[Path, ...]
+
+ @property
+ def is_single(self) -> bool:
+ """One reconstruction was written, so it is the one a follow-up offer would load."""
+ return len(self.written) == 1
class ConversionServiceProtocol(Protocol):
@@ -52,7 +77,7 @@ class ConversionServiceProtocol(Protocol):
def subscribe(self, handler: Callable[[ConversionResult], None]) -> None: ...
- def start(self, config: Config, input_path: Path) -> None: ...
+ def start(self, config: Config, plan: ConversionPlan) -> None: ...
def cancel(self) -> None: ...
@@ -80,11 +105,22 @@ def __init__(
self._is_operation_active = is_operation_active
self._msg_idle = language_manager["main.converter.message.status_idle"]
self._msg_cancelling = language_manager["main.converter.message.status_cancelling"]
+ self._stage_messages: Dict[ReconstructionStage, str] = {
+ ReconstructionStage.LOADING: language_manager["main.converter.message.stage_loading"],
+ ReconstructionStage.MATCHING: language_manager["main.converter.message.stage_matching"],
+ ReconstructionStage.DECODING: language_manager["main.converter.message.stage_decoding"],
+ ReconstructionStage.RENDERING: language_manager["main.converter.message.stage_rendering"],
+ }
self._phase: ConversionPhase = ConversionPhase.IDLE
self._input_path: Optional[Path] = None
self._output_path: Optional[Path] = None
+ self._written: Tuple[Path, ...] = ()
self._is_file: bool = True
+ self._stems_mode: bool = False
+ self._levels: StemLevels = StemLevels()
+ self._channel_cap: int = len(ChannelName)
+ self._hierarchy_mode: HierarchyMode = DEFAULT_STEMS_HIERARCHY_MODE
self._system_progress = SystemProgress()
self._service.subscribe(self._on_service_result)
@@ -94,13 +130,29 @@ def __init__(
self.on_error: Optional[Callable[[Exception], None]] = None
self.on_no_files_to_process: Optional[VoidCallback] = None
self.on_no_generators: Optional[VoidCallback] = None
+ self.on_target_exists: Optional[PathCallback] = None
self.on_load_file: Optional[PathCallback] = None
self.on_load_directory: Optional[VoidCallback] = None
- self.on_cancelled: Optional[VoidCallback] = None
+ self.on_canceled: Optional[VoidCallback] = None
self.generate_library: Optional[VoidCallback] = None
self.cancel_library_generation: Optional[VoidCallback] = None
self.is_library_available: Optional[Callable[[], bool]] = None
+ @property
+ def stems_mode(self) -> bool:
+ """Several recordings are being gathered into one reconstruction."""
+ return self._stems_mode
+
+ @property
+ def source_count(self) -> int:
+ """How many recordings the stems list holds."""
+ return self._levels.count
+
+ @property
+ def room_for_sources(self) -> int:
+ """How many more recordings the stems list has room for."""
+ return MAX_STEM_SOURCES - self.source_count
+
@property
def is_active(self) -> bool:
"""A conversion is occupying resources from the moment of request (the WAITING phase, during
@@ -129,15 +181,117 @@ def set_input_path(self, input_path: Path, convert: bool = False) -> None:
if convert:
self.start_conversion()
- def start_conversion(self) -> None:
+ def select_source(self, path: Path) -> None:
+ """Answers a recording picked in the explorer: it joins the list, or becomes the input.
+
+ In stems mode a pick adds to the setup being built, so a reader gathers a conversion by
+ clicking the recordings it mixes. Otherwise it is the single thing to convert.
+ """
+ if self._stems_mode:
+ self.add_sources([path])
+ return
+
+ self.set_input_path(path)
+
+ def add_sources(self, paths: Sequence[Path]) -> None:
+ """Adds recordings to the stems list, up to the room it has left.
+
+ A path already listed keeps the row it has, so adding it again leaves the setup as it is.
+ """
+ enabled = frozenset(self._config_manager.config.generation.channels)
+ for path in paths:
+ if self._levels.count >= MAX_STEM_SOURCES:
+ break
+
+ self._levels = self._levels.add(StemSource(path=path, channels=enabled))
+
+ self._refresh_setup()
+
+ def remove_source(self, path: Path) -> None:
+ """Takes a recording out of the stems list."""
+ self._levels = self._levels.remove(path)
+ self._refresh_setup()
+
+ def set_source_channels(self, path: Path, channels: FrozenSet[ChannelName]) -> None:
+ """Names the channels one recording may take, among the ones the reader was offered.
+
+ A channel the configuration leaves out reaches no checkbox, so the recording keeps
+ whatever it was given for it and gets that choice back when the channel returns.
+ """
+ enabled = frozenset(self._config_manager.config.generation.channels)
+ self._apply(
+ self._levels.replace_source(
+ path,
+ lambda source: source.with_channels((source.channels - enabled) | channels),
+ )
+ )
+
+ def move_source_within_level(self, path: Path, offset: int) -> None:
+ """Moves a recording past the neighbor it shares a level with."""
+ self._apply(self._levels.move_within_level(path, offset))
+
+ def join_source_level(self, path: Path, offset: int) -> None:
+ """Sends a recording to the level above or below the one it picks on."""
+ self._apply(self._levels.join_level(path, offset))
+
+ def isolate_source(self, path: Path) -> None:
+ """Gives a recording a level of its own, picking after the one it shared."""
+ self._apply(self._levels.isolate(path))
+
+ def move_source_onto(self, path: Path, target_path: Path) -> None:
+ """Moves a recording to the level and the place another one holds."""
+ self._apply(self._levels.move_onto(path, target_path))
+
+ def move_source_to_new_level(self, path: Path, position: int) -> None:
+ """Gives a recording a level of its own, in the slot the levels are broken at."""
+ self._apply(self._levels.move_to_new_level(path, position))
+
+ def set_stems_mode(self, stems_mode: bool) -> None:
+ """Switches between converting one selection and mixing several recordings into one.
+
+ Entering stems mode carries a selected file in as the first row. Leaving it keeps the
+ first row as the single selection, which is what the reader picked first.
+ """
+ if stems_mode == self._stems_mode:
+ return
+
+ self._stems_mode = stems_mode
+ if stems_mode:
+ self._enter_stems_mode()
+ else:
+ self._leave_stems_mode()
+
+ self._refresh_setup()
+
+ def set_channel_cap(self, channel_cap: int) -> None:
+ """Names how many channels one recording may hold in a frame, for every conversion."""
+ self._channel_cap = min(max(channel_cap, MIN_CHANNEL_CAP), self._max_channel_cap())
+ self._refresh_setup()
+
+ def set_hierarchy_mode(self, hierarchy_mode: HierarchyMode) -> None:
+ """Names how the levels take turns: round by round, or one level exhausted before the next."""
+ self._hierarchy_mode = hierarchy_mode
+ self._refresh_setup()
+
+ def start_conversion(self, confirmed: bool = False) -> None:
+ """Starts the run the current setup describes, asking first where it would write over work.
+
+ ``confirmed`` states that the reader has already answered for the file standing at the
+ target, which is what lets the prompt's answer come back and run.
+ """
if self._is_operation_active():
logger.warning("A conversion or library generation is already in progress")
return
- if not self._config_manager.config.generation.generators:
+ if not self._config_manager.config.generation.channels:
self.call(self.on_no_generators)
return
+ standing_target = self._standing_target()
+ if standing_target is not None and not confirmed:
+ self.call(self.on_target_exists, standing_target)
+ return
+
self._phase = ConversionPhase.WAITING
self._emit_view_model(self._language_manager["main.converter.message.status_waiting"], 0.0)
self.call(self.generate_library)
@@ -158,13 +312,13 @@ def close(self) -> None:
self._service.cleanup()
finally:
self._system_progress.clear()
+ self._written = ()
self._phase = ConversionPhase.IDLE
self._emit_view_model(self._msg_idle, 0.0)
def handle_load_request(self) -> None:
- if self._is_file:
- if self._output_path:
- self.call(self.on_load_file, self._output_path)
+ if len(self._written) == 1:
+ self.call(self.on_load_file, self._written[0])
else:
self.call(self.on_load_directory)
@@ -182,44 +336,80 @@ def _on_service_result(self, result: ConversionResult) -> None:
self._handle_progress_result(progress)
case ServiceIntermediate(data=progress):
self._handle_library_progress(progress)
- case ServiceSuccess(value=output_path):
- self._on_conversion_complete(output_path)
+ case ServiceSuccess(value=written):
+ self._on_conversion_complete(written)
case ServiceError(exception=exception):
self._on_conversion_error(exception)
- case ServiceCancelled():
+ case ServiceCanceled():
self._on_cancellation_complete()
- def _handle_progress_result(self, progress: ServiceProgress[Path]) -> None:
+ def _handle_progress_result(self, progress: ServiceProgress[ConversionItem]) -> None:
if self._phase == ConversionPhase.CANCELLING:
- total = max(progress.total, 1)
- self._emit_view_model(
- self._msg_cancelling,
- progress.completed / total,
- )
+ self._emit_view_model(self._msg_cancelling, progress.fraction)
return
self._phase = ConversionPhase.RUNNING
- self._system_progress.set(progress.completed, progress.total)
- eta_string = ETAEstimator.format_duration(progress.eta_seconds)
- total = max(progress.total, 1)
- status_text = self._language_manager["main.converter.template.progress_template"].format(
- progress.completed, progress.total
+ self._system_progress.set(
+ round(progress.fraction * SYSTEM_PROGRESS_STEPS),
+ SYSTEM_PROGRESS_STEPS,
+ )
+ self._emit_view_model(
+ self._compose_progress_text(progress),
+ progress.fraction,
+ input_path=self._display_input_path(progress),
)
- if eta_string:
- status_text += self._language_manager["global.dialog.template.time_estimation"].format(
- eta_string=eta_string
+ def _display_input_path(self, progress: ServiceProgress[ConversionItem]) -> Optional[Path]:
+ """The recording the run names itself by, or the one the reader chose."""
+ if progress.current_item is None:
+ return self._input_path
+
+ return progress.current_item.source
+
+ def _compose_progress_text(self, progress: ServiceProgress[ConversionItem]) -> str:
+ """What the run is doing, how far it has come, and how long it has left.
+
+ A batch is many reconstructions and a count says where it stands; a single job counts to
+ one, so it names the document it is writing instead. Either way the reconstruction under
+ way says which stage it is in, which is the whole of what a reader watching one job has.
+ """
+ return self._run_text(progress) + self._stage_text(progress) + self._estimate_text(progress)
+
+ def _run_text(self, progress: ServiceProgress[ConversionItem]) -> str:
+ if progress.total > SINGLE_JOB:
+ return self._language_manager["main.converter.template.progress_template"].format(
+ progress.completed, progress.total
)
- display_input_path = (
- to_path(str(progress.current_item)) if progress.current_item is not None else self._input_path
+ return self._language_manager["main.converter.template.single_progress_template"].format(
+ self._reconstruction_name()
)
- self._emit_view_model(
- status_text,
- progress.completed / total,
- input_path=display_input_path,
+
+ def _stage_text(self, progress: ServiceProgress[ConversionItem]) -> str:
+ step = progress.current_item.step if progress.current_item is not None else None
+ if step is None:
+ return ""
+
+ return self._language_manager["main.converter.template.stage_template"].format(
+ stage=self._stage_messages[step.stage],
+ completed=step.completed,
+ total=step.total,
)
+ def _estimate_text(self, progress: ServiceProgress[ConversionItem]) -> str:
+ eta_string = ETAEstimator.format_duration(progress.eta_seconds)
+ if not eta_string:
+ return ""
+
+ return self._language_manager["global.dialog.template.time_estimation"].format(eta_string=eta_string)
+
+ def _reconstruction_name(self) -> str:
+ """The document a single job writes, which is what a run of one is making."""
+ if self._output_path is not None:
+ return self._output_path.stem
+
+ return self._input_path.stem if self._input_path is not None else ""
+
def _handle_library_progress(self, progress: TaskProgress) -> None:
if self._phase != ConversionPhase.WAITING:
return
@@ -261,21 +451,128 @@ def _start_conversion(self) -> None:
assert self._input_path is not None, "Input path is not set"
config = self._config_manager.config.model_copy()
self._system_progress.initialize()
- self._service.start(config, self._input_path)
+ self._service.start(config, self._conversion_plan(config, self._input_path))
+
+ def _standing_target(self) -> Optional[Path]:
+ """The reconstruction this run would write over, where one stands.
+
+ A batch converts what is still to be written and keeps the rest, so it puts nothing to
+ the reader; a single conversion writes one file, and that is the one worth asking about.
+ """
+ if self._input_path is None:
+ return None
+
+ config = self._config_manager.config
+ targets = self._conversion_plan(config, self._input_path).existing_targets(config)
+ return targets[0] if targets else None
+
+ def _conversion_plan(self, config: Config, input_path: Path) -> ConversionPlan:
+ """What the request amounts to: one reconstruction from the recordings listed or the file
+ selected, or one per audio file the selected directory holds."""
+ setup = self._stems_setup(config)
+ if self._stems_mode:
+ return GroupConversion(sources=setup.sources, stems=setup.stems)
+
+ if self._is_file:
+ return GroupConversion(sources=(input_path,), stems=setup.stems)
+
+ return DirectoryConversion(directory=input_path, stems=setup.stems)
+
+ def _stems_setup(self, config: Config) -> ConversionSetup:
+ """The recordings and the setup the conversion runs with, carrying the channel cap.
+
+ In stems mode this is what the gathered levels amount to; otherwise it is one stem over
+ every enabled channel, which is the classic run's shape.
+ """
+ enabled = list(config.generation.channels)
+ if self._stems_mode:
+ return derive_conversion_setup(
+ self._levels,
+ enabled,
+ channel_cap=self._effective_channel_cap,
+ hierarchy_mode=self._hierarchy_mode,
+ )
+
+ return ConversionSetup(
+ sources=(),
+ stems=StemsConfig.single_entry(enabled, channel_cap=self._effective_channel_cap),
+ )
- def _on_conversion_complete(self, output_path: Path) -> None:
- if output_path.exists():
- self._output_path = output_path
+ @property
+ def _source_paths(self) -> Tuple[Path, ...]:
+ """The recordings that take part, in the order the conversion mixes them."""
+ return self._stems_setup(self._config_manager.config).sources
+
+ @property
+ def _effective_channel_cap(self) -> int:
+ """The cap a run holds to: what the reader asked for, within the channels now enabled."""
+ return min(self._channel_cap, self._max_channel_cap())
+
+ def _max_channel_cap(self) -> int:
+ return max(len(self._config_manager.config.generation.channels), MIN_CHANNEL_CAP)
+
+ def _apply(self, levels: StemLevels) -> None:
+ """Takes up a rewritten stems list and follows it wherever the setup changed."""
+ self._levels = levels
+ self._refresh_setup()
+
+ def _enter_stems_mode(self) -> None:
+ enabled = frozenset(self._config_manager.config.generation.channels)
+ if self._levels.count == 0 and self._input_path is not None and self._is_file:
+ self._levels = self._levels.add(StemSource(path=self._input_path, channels=enabled))
+
+ def _leave_stems_mode(self) -> None:
+ if self._levels.count:
+ self._levels = self._levels.keep_first()
+ self._assign_paths(self._levels.paths[0], self._config_manager.config)
+
+ def _refresh_setup(self) -> None:
+ """Follows the setup wherever it changed: the destination it now names, and the view."""
+ self._update_stems_output_path()
+ if not self.is_active:
+ self._phase = ConversionPhase.IDLE
+ self._emit_view_model(self._msg_idle, 0.0)
+
+ def _update_stems_output_path(self) -> None:
+ if not self._stems_mode:
+ return
+
+ sources = self._source_paths
+ if sources:
+ self._output_path = group_output_path(self._config_manager.config, sources)
+
+ def _stem_rows(self, config: Config) -> Tuple[StemRowViewModel, ...]:
+ """The gathered recordings as the panel reads them, each stating where it stands.
+
+ A gathered recording is named by its path, so the list reports every gesture under the
+ path it landed on, and it offers a box on every channel the configuration enables. A
+ recording that has left the disk since it was gathered reports itself as missing.
+ """
+ enabled = list(config.generation.channels)
+ return tuple(
+ StemRowViewModel(
+ key=str(source.path),
+ path=source.path,
+ channels=frozenset(effective_channels(source, enabled)),
+ offered_channels=frozenset(enabled),
+ available=source.path.is_file(),
+ level=level_index,
+ position=position,
+ level_size=len(level),
+ level_count=self._levels.level_count,
+ )
+ for level_index, level in enumerate(self._levels.levels)
+ for position, source in enumerate(level)
+ )
+
+ def _on_conversion_complete(self, written: Tuple[Path, ...]) -> None:
+ self._written = written
+ if len(written) == 1:
+ self._output_path = written[0]
self._phase = ConversionPhase.COMPLETED
self._emit_view_model(self._language_manager["main.converter.message.status_reconstruction_completed"], 1.0)
- self.call(
- self.on_success,
- ConversionSuccess(
- is_file=self._is_file,
- output_path=self._output_path,
- ),
- )
+ self.call(self.on_success, ConversionSuccess(written=written))
def _on_conversion_error(self, exception: Exception) -> None:
self._system_progress.error()
@@ -288,10 +585,10 @@ def _on_conversion_error(self, exception: Exception) -> None:
self.call(self.on_error, exception)
def _on_cancellation_complete(self) -> None:
- self._phase = ConversionPhase.CANCELLED
- self._emit_view_model(self._language_manager["main.converter.message.status_cancelled"], 0.0)
+ self._phase = ConversionPhase.CANCELED
+ self._emit_view_model(self._language_manager["main.converter.message.status_canceled"], 0.0)
self._schedule_return_to_idle()
- self.call(self.on_cancelled)
+ self.call(self.on_canceled)
def _schedule_return_to_idle(self) -> None:
CallbackQueue.add(
@@ -302,10 +599,13 @@ def _schedule_return_to_idle(self) -> None:
def _compose_action_label(self, input_path: Optional[Path]) -> str:
"""The label the single action button shows: the cancel label while a conversion holds
- resources, otherwise the convert label named after the selected input."""
+ resources, otherwise the convert label named after what it would convert."""
if self._phase in ACTIVE_PHASES:
return self._language_manager["main.converter.label.cancel_button"]
+ if self._stems_mode:
+ return self._compose_stems_action_label()
+
base = (
self._language_manager["main.converter.label.convert_sample_button"]
if self._is_file
@@ -316,12 +616,22 @@ def _compose_action_label(self, input_path: Optional[Path]) -> str:
return self._language_manager["main.converter.template.convert_label_template"].format(base, input_path.name)
+ def _compose_stems_action_label(self) -> str:
+ """The stems label, named after how many recordings take part."""
+ base = self._language_manager["main.converter.label.convert_stems_button"]
+ playing = len(self._source_paths)
+ if not playing:
+ return base
+
+ return self._language_manager["main.converter.template.convert_label_template"].format(base, playing)
+
def _emit_view_model(
self,
status_text: str,
progress: float,
input_path: Optional[Path] = None,
) -> None:
+ config = self._config_manager.config
display_output = (
self._output_path if self._output_path is not None else self._config_manager.get_reconstructions_directory()
)
@@ -335,5 +645,12 @@ def _emit_view_model(
output_path=display_output,
is_file=self._is_file,
other_operation_active=self._is_operation_active(),
+ stems_mode=self._stems_mode,
+ stem_sources=self._stem_rows(config),
+ enabled_channels=frozenset(config.generation.channels),
+ channel_cap=self._effective_channel_cap,
+ max_channel_cap=self._max_channel_cap(),
+ hierarchy_mode=self._hierarchy_mode,
+ max_sources=MAX_STEM_SOURCES,
)
self.call(self.on_view_changed, view_model)
diff --git a/src/sampletones_application/logic/main/stems.py b/src/sampletones_application/logic/main/stems.py
new file mode 100644
index 000000000..236a77ff4
--- /dev/null
+++ b/src/sampletones_application/logic/main/stems.py
@@ -0,0 +1,259 @@
+from dataclasses import dataclass, replace
+from pathlib import Path
+from typing import Callable, FrozenSet, List, Optional, Self, Sequence, Tuple
+
+from sampletones_core.constants.enums import ChannelName, HierarchyMode
+from sampletones_core.reconstructions.reconstructor.stems.configs.config import StemsConfig
+from sampletones_core.reconstructions.reconstructor.stems.configs.entry import StemEntry
+from sampletones_core.reconstructions.reconstructor.stems.configs.hierarchy import StemsHierarchy
+
+Level = Tuple["StemSource", ...]
+
+
+@dataclass(frozen=True)
+class StemSource:
+ """One recording in a stems conversion, together with the channels it may take."""
+
+ path: Path
+ channels: FrozenSet[ChannelName]
+
+ def with_channels(self, channels: FrozenSet[ChannelName]) -> Self:
+ return replace(self, channels=channels)
+
+
+@dataclass(frozen=True)
+class ConversionSetup:
+ """What a stems conversion runs with: the recordings it mixes and the setup handing out channels.
+
+ Both sides are built from one pass over the levels, so the entry ids the assignment records
+ name the recordings in the order the job mixes them.
+ """
+
+ sources: Tuple[Path, ...]
+ stems: StemsConfig
+
+
+@dataclass(frozen=True)
+class StemLevels:
+ """The recordings a stems conversion gathers, in the precedence levels they pick on.
+
+ A level holds the recordings that compete on cost; the levels pick in the order they are
+ listed. Position within a level is the order the entries are numbered in, which is what
+ settles a tie between two equal-cost choices. Every gesture answers with a new value whose
+ levels are all occupied, so the bands a reader sees stay consecutive.
+ """
+
+ levels: Tuple[Level, ...] = ()
+
+ @classmethod
+ def of(cls, levels: Sequence[Sequence[StemSource]]) -> Self:
+ """Builds a value from levels given in any shape, leaving out the ones holding nothing."""
+ return cls(levels=tuple(tuple(level) for level in levels if level))
+
+ @property
+ def sources(self) -> Tuple[StemSource, ...]:
+ """Every recording, in the order it is mixed and numbered."""
+ return tuple(source for level in self.levels for source in level)
+
+ @property
+ def paths(self) -> Tuple[Path, ...]:
+ return tuple(source.path for source in self.sources)
+
+ @property
+ def count(self) -> int:
+ return len(self.sources)
+
+ @property
+ def level_count(self) -> int:
+ return len(self.levels)
+
+ def holds(self, path: Path) -> bool:
+ return any(source.path == path for source in self.sources)
+
+ def level_of(self, path: Path) -> int:
+ """The level the recording picks on, counted from the first."""
+ for level_index, level in enumerate(self.levels):
+ if any(source.path == path for source in level):
+ return level_index
+
+ raise KeyError(f"{path} is not gathered in this conversion")
+
+ def position_of(self, path: Path) -> int:
+ """The place the recording takes among the ones sharing its level."""
+ level = self.levels[self.level_of(path)]
+ return next(index for index, source in enumerate(level) if source.path == path)
+
+ def add(self, source: StemSource) -> Self:
+ """Gathers another recording, on the level the first recordings picked on."""
+ if self.holds(source.path):
+ return self
+
+ levels = self._mutable()
+ if not levels:
+ return self.of([[source]])
+
+ levels[0].append(source)
+ return self.of(levels)
+
+ def remove(self, path: Path) -> Self:
+ """Lets a recording go, together with the level it emptied."""
+ return self.of(self._without(path))
+
+ def replace_source(self, path: Path, change: Callable[[StemSource], StemSource]) -> Self:
+ """Rewrites one recording where it stands, leaving the rest of the setup as it is."""
+ return self.of(
+ [[change(source) if source.path == path else source for source in level] for level in self.levels]
+ )
+
+ def keep_first(self) -> Self:
+ """Keeps the recording that picks first, which is the one a classic conversion carries."""
+ sources = self.sources
+ return self.of([[sources[0]]]) if sources else self.of([])
+
+ def move_within_level(self, path: Path, offset: int) -> Self:
+ """Moves a recording past the neighbor it shares a level with, changing which of them ties first."""
+ source = self._source(path)
+ if source is None:
+ return self
+
+ level_index = self.level_of(path)
+ position = self.position_of(path) + offset
+ level = list(self.levels[level_index])
+ if not 0 <= position < len(level):
+ return self
+
+ level.remove(source)
+ level.insert(position, source)
+ levels = self._mutable()
+ levels[level_index] = level
+ return self.of(levels)
+
+ def join_level(self, path: Path, offset: int) -> Self:
+ """Sends a recording to the neighboring level, where it picks with that level's recordings."""
+ source = self._source(path)
+ if source is None:
+ return self
+
+ target = self.level_of(path) + offset
+ if not 0 <= target < self.level_count:
+ return self
+
+ levels = self._without(path)
+ levels[target].append(source)
+ return self.of(levels)
+
+ def isolate(self, path: Path) -> Self:
+ """Gives a recording a level of its own, picking directly after the one it shared."""
+ source = self._source(path)
+ if source is None or len(self.levels[self.level_of(path)]) == 1:
+ return self
+
+ levels = self._without(path)
+ levels.insert(self.level_of(path) + 1, [source])
+ return self.of(levels)
+
+ def move_onto(self, path: Path, target_path: Path) -> Self:
+ """Moves a recording to the level and the place another one holds."""
+ source = self._source(path)
+ if source is None or path == target_path or not self.holds(target_path):
+ return self
+
+ levels = self._without(path)
+ for level in levels:
+ for position, candidate in enumerate(level):
+ if candidate.path == target_path:
+ level.insert(position, source)
+ return self.of(levels)
+
+ return self
+
+ def move_to_new_level(self, path: Path, position: int) -> Self:
+ """Gives a recording a level of its own, in the slot the levels are broken at.
+
+ ``position`` counts the gaps a reader sees: zero is above the first level and the level
+ count is below the last, so the slot names itself the same way whichever level the
+ recording is leaving.
+ """
+ source = self._source(path)
+ if source is None:
+ return self
+
+ level_index = self.level_of(path)
+ levels = self._without(path)
+ target = position
+ if not levels[level_index]:
+ del levels[level_index]
+ target = position - 1 if position > level_index else position
+ if target == level_index:
+ return self
+
+ levels.insert(target, [source])
+ return self.of(levels)
+
+ def _source(self, path: Path) -> Optional[StemSource]:
+ return next((source for source in self.sources if source.path == path), None)
+
+ def _mutable(self) -> List[List[StemSource]]:
+ return [list(level) for level in self.levels]
+
+ def _without(self, path: Path) -> List[List[StemSource]]:
+ """The levels with one recording taken out, keeping a level it emptied for the callers that count on it."""
+ return [[source for source in level if source.path != path] for level in self.levels]
+
+
+def effective_channels(
+ source: StemSource,
+ enabled_channels: Sequence[ChannelName],
+) -> List[ChannelName]:
+ """The channels a source may take in the run being set up, in the order the run enables them.
+
+ A source keeps whichever of its channels the configuration still enables. One left holding
+ none takes no part in the conversion, which is what unticking every channel of a row says.
+ """
+ return [channel_name for channel_name in enabled_channels if channel_name in source.channels]
+
+
+def derive_conversion_setup(
+ levels: StemLevels,
+ enabled_channels: Sequence[ChannelName],
+ *,
+ channel_cap: int,
+ hierarchy_mode: HierarchyMode,
+) -> ConversionSetup:
+ """Turns the levels a reader gathered into the recordings and the setup a conversion runs with.
+
+ Recordings holding no enabled channel take no part, so they reach neither the mix nor the
+ entries. What remains is numbered in list order, which is the id the conversion records per
+ frame and a stem selection later reads back.
+ """
+ taking_part = [
+ [(source, effective_channels(source, enabled_channels)) for source in level] for level in levels.levels
+ ]
+ playing = [[pair for pair in level if pair[1]] for level in taking_part]
+ ordered = [pair for level in playing if level for pair in level]
+
+ entries = [StemEntry(id=stem_id, channels=channels) for stem_id, (_source, channels) in enumerate(ordered)]
+ return ConversionSetup(
+ sources=tuple(source.path for source, _channels in ordered),
+ stems=StemsConfig(
+ entries=entries,
+ hierarchy=_hierarchy(playing, hierarchy_mode),
+ channel_cap=channel_cap,
+ ),
+ )
+
+
+def _hierarchy(
+ playing: Sequence[Sequence[Tuple[StemSource, List[ChannelName]]]],
+ hierarchy_mode: HierarchyMode,
+) -> StemsHierarchy:
+ levels: List[List[int]] = []
+ stem_id = 0
+ for level in playing:
+ if not level:
+ continue
+
+ levels.append([stem_id + offset for offset in range(len(level))])
+ stem_id += len(level)
+
+ return StemsHierarchy(levels=levels, mode=hierarchy_mode)
diff --git a/src/sampletones_application/logic/project/controller.py b/src/sampletones_application/logic/project/controller.py
index fd6b4b954..be6b0aae0 100644
--- a/src/sampletones_application/logic/project/controller.py
+++ b/src/sampletones_application/logic/project/controller.py
@@ -2,14 +2,17 @@
from pathlib import Path
from typing import Iterator, Optional
-from sampletones_core.constants.enums import GeneratorName
+from sampletones_core.constants.enums import ChannelName, FeatureKey
from sampletones_core.constants.general import MAX_TRANSPOSE, MAX_VOLUME, MIN_TRANSPOSE
+from sampletones_core.exports.request import ProjectExport
+from sampletones_core.features.envelope import Envelope
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.project.voices.instrument import Instrument
+from sampletones_core.project.voices.sample import Sample
+from sampletones_core.project.voices.voice import VoiceUnion
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
@@ -38,7 +41,7 @@ def __init__(self, project_manager: ProjectManager) -> None:
self.on_project_replaced: Optional[VoidCallback] = None
self.on_info_changed: Optional[VoidCallback] = None
self.on_settings_changed: Optional[VoidCallback] = None
- self.on_samples_changed: Optional[VoidCallback] = None
+ self.on_voices_changed: Optional[VoidCallback] = None
self.on_song_changed: Optional[VoidCallback] = None
self.on_mutation: Optional[VoidCallback] = None
self.on_saved: Optional[VoidCallback] = None
@@ -65,12 +68,12 @@ def name(self) -> str:
return self._project_manager.name
@property
- def has_samples(self) -> bool:
- return bool(self.project.samples)
+ def has_voices(self) -> bool:
+ return bool(self.project.voices)
@property
- def sample_count(self) -> int:
- return len(self.project.samples)
+ def voice_count(self) -> int:
+ return len(self.project.voices)
@property
def is_dirty(self) -> bool:
@@ -195,12 +198,56 @@ def add_sample(self, reconstruction: Reconstruction, name: str) -> Sample:
"""
reconstruction.detach_source()
sample = Sample(name=name, reconstruction=reconstruction)
- self.project.samples.append(sample)
+ self.project.voices.append(sample)
self._touch()
- self._announce(self.on_samples_changed)
+ self._announce(self.on_voices_changed)
return sample
- def replace_sample_reconstruction(self, sample_id: str, reconstruction: Reconstruction) -> None:
+ def add_instrument(self, instrument: Instrument) -> Instrument:
+ """Appends a hand-written voice, which the voice list holds and the tracker can name.
+
+ An instrument is its own record, so whoever made it — a reader asking for a new one, an
+ instrument file read from disk, a sample's channel frozen into envelopes — hands the
+ whole voice over and the pool takes it as it stands.
+ """
+ self.project.voices.append(instrument)
+ self._touch()
+ self._announce(self.on_voices_changed)
+ return instrument
+
+ def set_instrument_envelope(
+ self,
+ voice_id: str,
+ feature_key: FeatureKey,
+ envelope: Envelope[int],
+ ) -> None:
+ """Writes one dimension of an instrument's envelopes, emptying it to leave it to the channel.
+
+ Raises:
+ TypeError: If ``voice_id`` names a voice that writes no envelopes of its own.
+ """
+ instrument = self._instrument(voice_id)
+ instrument.envelopes = instrument.envelopes.with_envelope(feature_key, envelope)
+ instrument.invalidate()
+ self._touch()
+ self._announce(self.on_voices_changed)
+ self._announce(self.on_song_changed)
+
+ def _instrument(self, voice_id: str) -> Instrument:
+ voice = self.project.voices[voice_id]
+ if not isinstance(voice, Instrument):
+ raise TypeError(f"Voice '{voice_id}' is no instrument")
+
+ return voice
+
+ def _sample(self, voice_id: str) -> Sample:
+ voice = self.project.voices[voice_id]
+ if not isinstance(voice, Sample):
+ raise TypeError(f"Voice '{voice_id}' is no sample")
+
+ return voice
+
+ def replace_sample_reconstruction(self, voice_id: str, reconstruction: Reconstruction) -> None:
"""Substitutes a sample's reconstruction, detaching its local source-audio origin.
The sample keeps its id, so every pattern row referencing it stays valid and the tracker
@@ -208,78 +255,74 @@ def replace_sample_reconstruction(self, sample_id: str, reconstruction: Reconstr
reconstruction, the project stays a self-contained, shareable artifact.
"""
reconstruction.detach_source()
- self.project.samples[sample_id].reconstruction = reconstruction
+ voice = self._sample(voice_id)
+ voice.reconstruction = reconstruction
self._touch()
- self._announce(self.on_samples_changed)
+ self._announce(self.on_voices_changed)
self._announce(self.on_song_changed)
- def rename_sample(self, sample_id: str, name: str) -> None:
- self.project.samples[sample_id].name = name
+ def rename_voice(self, voice_id: str, name: str) -> None:
+ self.project.voices[voice_id].name = name
self._touch()
- self._announce(self.on_samples_changed)
+ self._announce(self.on_voices_changed)
self._announce(self.on_song_changed)
- def set_sample_loop(self, sample_id: str, loop: bool) -> None:
- self.project.samples[sample_id].loop = loop
- self._touch()
- self._announce(self.on_samples_changed)
-
- def is_sample_used(self, sample_id: str) -> bool:
- return self.song.references_sample(sample_id)
+ def is_voice_used(self, voice_id: str) -> bool:
+ return self.song.references_voice(voice_id)
- def remove_sample(self, sample_id: str) -> None:
- self.project.samples.pop(sample_id)
- self.song.clear_sample_references(sample_id)
+ def remove_voice(self, voice_id: str) -> None:
+ self.project.voices.pop(voice_id)
+ self.song.clear_voice_references(voice_id)
self._touch()
- self._announce(self.on_samples_changed)
+ self._announce(self.on_voices_changed)
self._announce(self.on_song_changed)
- def duplicate_sample(self, sample_id: str) -> Sample:
- """Appends an independent copy of a sample (same name and loop flag).
+ def duplicate_voice(self, voice_id: str) -> VoiceUnion:
+ """Appends an independent copy of a voice (same name and loop point).
- The copy is appended, so existing samples keep their positions; it keeps the
+ The copy is appended, so existing voices keep their positions; it keeps the
source name (like duplicated patterns), leaving renaming to the user.
"""
- clone = self.project.samples[sample_id].clone()
- self.project.samples.append(clone)
+ clone = self.project.voices[voice_id].clone()
+ self.project.voices.append(clone)
self._touch()
- self._announce(self.on_samples_changed)
+ self._announce(self.on_voices_changed)
return clone
- def move_sample(self, sample_id: str, to_index: int) -> None:
- """Reorders the sample pool.
+ def move_voice(self, voice_id: str, to_index: int) -> None:
+ """Reorders the voice pool.
- Pattern rows reference samples by stable id, so reordering keeps every
+ Pattern rows reference voices by stable id, so reordering keeps every
reference valid; it only changes the positional index the tracker displays,
hence ``on_song_changed`` fires so the grid re-renders those indices.
"""
- self.project.samples.move(sample_id, to_index)
+ self.project.voices.move(voice_id, to_index)
self._touch()
- self._announce(self.on_samples_changed)
+ self._announce(self.on_voices_changed)
self._announce(self.on_song_changed)
- def add_pattern(self, generator: GeneratorName) -> int:
- index = self.song.add_pattern(generator)
+ def add_pattern(self, channel: ChannelName) -> int:
+ index = self.song.add_pattern(channel)
self._touch()
self._announce(self.on_song_changed)
return index
def clone_pattern(
self,
- generator: GeneratorName,
+ channel: ChannelName,
pattern_index: int,
) -> int:
- clone_index = self.song.clone_pattern(generator, pattern_index)
+ clone_index = self.song.clone_pattern(channel, pattern_index)
self._touch()
self._announce(self.on_song_changed)
return clone_index
def remove_pattern(
self,
- generator: GeneratorName,
+ channel: ChannelName,
pattern_index: int,
) -> None:
- self.song.remove_pattern(generator, pattern_index)
+ self.song.remove_pattern(channel, pattern_index)
self._touch()
self._announce(self.on_song_changed)
@@ -297,7 +340,7 @@ def _clamp_volume(self, volume: Optional[int]) -> Optional[int]:
def _existing_row(
self,
- generator: GeneratorName,
+ channel: ChannelName,
pattern_index: int,
row_index: int,
) -> Row:
@@ -307,7 +350,7 @@ def _existing_row(
clear edits treat that as a blank row, and :meth:`set_row` materialises the
pattern when it writes.
"""
- pattern = self.song.pattern(generator, pattern_index)
+ pattern = self.song.pattern(channel, pattern_index)
if pattern is None:
return Row()
@@ -315,7 +358,7 @@ def _existing_row(
def set_row(
self,
- generator: GeneratorName,
+ channel: ChannelName,
pattern_index: int,
row_index: int,
*,
@@ -333,12 +376,12 @@ def set_row(
transpose=self._clamp_transpose(transpose),
volume=self._clamp_volume(volume),
)
- channel = self.song[generator]
- channel.ensure_pattern(
+ channel_pool = self.song[channel]
+ channel_pool.ensure_pattern(
pattern_index,
self.song.rows_per_pattern,
)
- channel.set_row(
+ channel_pool.set_row(
pattern_index,
row_index,
row,
@@ -348,7 +391,7 @@ def set_row(
def update_row(
self,
- generator: GeneratorName,
+ channel: ChannelName,
pattern_index: int,
row_index: int,
*,
@@ -362,9 +405,9 @@ def update_row(
single subcolumn while the rest of the row carries over. For clearing a
subcolumn, :meth:`set_row` interprets ``None`` as "clear".
"""
- existing = self._existing_row(generator, pattern_index, row_index)
+ existing = self._existing_row(channel, pattern_index, row_index)
self.set_row(
- generator,
+ channel,
pattern_index,
row_index,
command=command if command is not None else existing.command,
@@ -374,11 +417,11 @@ def update_row(
def clear_row(
self,
- generator: GeneratorName,
+ channel: ChannelName,
pattern_index: int,
row_index: int,
*,
- instrument: bool = True,
+ voice: bool = True,
transpose: bool = True,
volume: bool = True,
) -> None:
@@ -388,12 +431,12 @@ def clear_row(
subcolumn to keep its current value. With no selectors this is the inverse
of :meth:`update_row`.
"""
- existing = self._existing_row(generator, pattern_index, row_index)
+ existing = self._existing_row(channel, pattern_index, row_index)
self.set_row(
- generator,
+ channel,
pattern_index,
row_index,
- command=None if instrument else existing.command,
+ command=None if voice else existing.command,
transpose=None if transpose else existing.transpose,
volume=None if volume else existing.volume,
)
@@ -410,11 +453,11 @@ def insert_frame(self, position: int) -> None:
def set_order_entry(
self,
- generator: GeneratorName,
+ channel: ChannelName,
position: int,
pattern_index: Optional[int],
) -> None:
- self.song.set_order_entry(position, generator, pattern_index)
+ self.song.set_order_entry(position, channel, pattern_index)
self._touch()
self._announce(self.on_song_changed)
diff --git a/src/sampletones_application/logic/reconstruction/audio_location.py b/src/sampletones_application/logic/reconstruction/audio_location.py
index cb93a10ed..20c5b9fa3 100644
--- a/src/sampletones_application/logic/reconstruction/audio_location.py
+++ b/src/sampletones_application/logic/reconstruction/audio_location.py
@@ -1,10 +1,10 @@
from pathlib import Path
-from typing import Optional
+from typing import Tuple
from sampletones_core.reconstructions import Reconstruction
-def resolve_original_audio(filepath: Path) -> Optional[Path]:
+def resolve_original_audio(filepath: Path) -> Tuple[Path, ...]:
"""Reads a browsed reconstruction to recover the original audio location it records."""
reconstruction = Reconstruction.load(filepath)
return reconstruction.audio_filepath
diff --git a/src/sampletones_application/logic/reconstruction/audition.py b/src/sampletones_application/logic/reconstruction/audition.py
new file mode 100644
index 000000000..5b84be2bd
--- /dev/null
+++ b/src/sampletones_application/logic/reconstruction/audition.py
@@ -0,0 +1,176 @@
+from typing import Callable, Optional
+
+import numpy as np
+
+from sampletones_application.config.managers.session import SessionManager
+from sampletones_application.constants.instruments import AUDITION_GENERATOR, AUDITION_TICKS
+from sampletones_application.logic.project.controller import ProjectController
+from sampletones_application.logic.reconstruction.editing import (
+ InstrumentAuditionProtocol,
+)
+from sampletones_application.logic.shared.playback_priority import PlaybackPriority
+from sampletones_application.utils.callbacks.queue import CallbackQueue
+from sampletones_application.view_model.reconstruction.waveform import (
+ InstrumentWaveformViewModel,
+)
+from sampletones_core.audio import AudioDeviceManager
+from sampletones_core.configs import Config
+from sampletones_core.constants.enums import ChannelName, GeneratorName
+from sampletones_core.features import generator_channel, speaks_in_periods
+from sampletones_core.performance.audition import audition_audio, audition_ticks
+from sampletones_core.project.voices.instrument import Instrument
+from sampletones_shared.constants.music import OCTAVE_OFFSET, OCTAVE_SEMITONES
+from sampletones_shared.exceptions import PlaybackError
+from sampletones_shared.logger import logger
+from sampletones_shared.utils.callbacks import CallbackMixin
+
+
+class InstrumentAuditionLogic(CallbackMixin):
+ """Sounds and draws the instrument the Reconstructions tab is showing, on one generator.
+
+ An instrument stands on no recording, so hearing one means playing it and seeing one means
+ rendering it. Both read the same choice — the generator the reader is auditioning it as — so
+ the choice is held here and what the plot card draws is what the note keys sound. The audition
+ plays at preview priority, so it yields to playback the reader asked for and answers Stop the
+ way every other preview does.
+ """
+
+ def __init__(
+ self,
+ editor: InstrumentAuditionProtocol,
+ project_controller: ProjectController,
+ session_manager: SessionManager,
+ audio_device_manager: AudioDeviceManager,
+ ) -> None:
+ self._editor = editor
+ self._controller = project_controller
+ self._session_manager = session_manager
+ self._audio_device_manager = audio_device_manager
+ self._generator_name: GeneratorName = AUDITION_GENERATOR
+
+ self.on_audition_error: Optional[Callable[[Exception], None]] = None
+ self.on_waveform_changed: Optional[Callable[[Optional[InstrumentWaveformViewModel]], None]] = None
+ self.on_position_changed: Optional[Callable[[int], None]] = None
+
+ def set_generator(self, generator_name: GeneratorName) -> None:
+ """Takes the generator the voice is auditioned as, redrawing it as the one now chosen."""
+ self._generator_name = generator_name
+ self.refresh()
+
+ def refresh(self) -> None:
+ """States the waveform of whatever the tab has in front of it, which an instrument alone has.
+
+ A recording draws the audio it was made from, so the card is left to it; an instrument is
+ redrawn whenever its envelopes change, which is what keeps the picture answering the edit.
+ """
+ self.call(self.on_waveform_changed, self._waveform())
+
+ def sound(self, semitone: int) -> None:
+ """Sounds the instrument in front of the tab at one key of the keyboard's two octaves.
+
+ Args:
+ semitone: How far the key pressed stands above the C of the octave in force.
+ """
+ instrument = self._editor.instrument
+ if instrument is None:
+ return
+
+ channel_name = generator_channel(self._generator_name)
+ audio = audition_audio(
+ instrument,
+ channel_name,
+ self._audition_config(),
+ pitch=self._sounding_pitch(instrument, channel_name, semitone),
+ ticks=audition_ticks(instrument, cap=AUDITION_TICKS),
+ )
+ if audio is None:
+ return
+
+ self._play(audio, instrument.id)
+
+ def _waveform(self) -> Optional[InstrumentWaveformViewModel]:
+ """The audio the open instrument makes at the pitch it stands at, drawn as it sounds.
+
+ The plot shows the voice as it is rather than at a note just pressed, so it is rendered at
+ the pitch the instrument itself is measured against.
+ """
+ instrument = self._editor.instrument
+ if instrument is None:
+ return None
+
+ channel_name = generator_channel(self._generator_name)
+ config = self._audition_config()
+ audio = audition_audio(
+ instrument,
+ channel_name,
+ config,
+ pitch=instrument.reference(channel_name),
+ ticks=audition_ticks(instrument, cap=AUDITION_TICKS),
+ )
+ if audio is None:
+ return None
+
+ return InstrumentWaveformViewModel(
+ name=instrument.name,
+ channel_name=channel_name,
+ audio=audio,
+ frame_length=config.frame_length,
+ )
+
+ def _sounding_pitch(
+ self,
+ instrument: Instrument,
+ channel_name: ChannelName,
+ semitone: int,
+ ) -> int:
+ """The value the channel sounds the voice at, which is a note or a noise period.
+
+ Two rows of keys name two octaves above the one in force, the way the tracker's own note
+ entry reads them. The noise channel selects one of sixteen periods instead of naming
+ notes, so a key there sounds the instrument at the period it states.
+ """
+ if speaks_in_periods(channel_name):
+ return instrument.reference(channel_name)
+
+ return (self._session_manager.octave + OCTAVE_OFFSET) * OCTAVE_SEMITONES + semitone
+
+ def _audition_config(self) -> Config:
+ settings = self._controller.project.settings
+ return Config().with_library(
+ nes_frequency=settings.nes_frequency,
+ sample_rate=settings.sample_rate,
+ )
+
+ def _play(self, audio: np.ndarray, voice_id: str) -> None:
+ """Sounds the rendering, following it with a cursor while the audition holds the output.
+
+ A preview yields to playback the reader asked for, so the cursor is followed only once the
+ audition has the device: an audition that stands aside leaves the mark of whatever is
+ sounding where it is.
+ """
+ try:
+ sounding = self._audio_device_manager.play(
+ audio,
+ update=True,
+ priority=PlaybackPriority.PREVIEW,
+ owner=self,
+ )
+ except (PlaybackError, ValueError) as exception:
+ logger.error_with_traceback(
+ exception,
+ f"Failed to audition instrument: {voice_id}",
+ )
+ self.call(self.on_audition_error, exception)
+ return
+
+ if sounding:
+ self._audio_device_manager.set_position_callback(self._on_device_position)
+
+ def _on_device_position(self, position: int) -> None:
+ """Carries the sounding position from the playback thread to the card that draws it.
+
+ The device reports from the thread writing the audio, and the mark is a widget, so the
+ report crosses to the render thread the way every other background result does. The device
+ reports a final zero as it winds down, which is what takes the mark off the card.
+ """
+ CallbackQueue.add(self.call, self.on_position_changed, position)
diff --git a/src/sampletones_application/logic/reconstruction/browser/tree/collapse.py b/src/sampletones_application/logic/reconstruction/browser/tree/collapse.py
index 1bb4026c3..44ffafb69 100644
--- a/src/sampletones_application/logic/reconstruction/browser/tree/collapse.py
+++ b/src/sampletones_application/logic/reconstruction/browser/tree/collapse.py
@@ -15,7 +15,7 @@ def collapse_single_child_containers(node: TreeNode) -> None:
into groups as soon as a second configuration arrives.
The row that survives keeps its node type, its path, its configuration and its children, so its
- click behaviour, theme, context menu and favorite star carry over from before the fold. The two
+ click behavior, theme, context menu and favorite star carry over from before the fold. The two
branch roots stay in place, since each names a way of reading the whole tree, and a folder the disk
holds stays a folder of its own, since the configuration branch mirrors the disk.
"""
@@ -34,16 +34,25 @@ def _can_fold(node: TreeNode) -> bool:
if node.node_type not in ARTIFICIAL_CONTAINERS or len(node.children) != 1:
return False
- return not _siblings_hold(node, _joined_name(node, node.children[0]))
+ return not _siblings_hold(
+ node,
+ parent,
+ _joined_name(node, node.children[0]),
+ )
-def _siblings_hold(node: TreeNode, name: str) -> bool:
+def _siblings_hold(
+ node: TreeNode,
+ parent: TreeNode,
+ name: str,
+) -> bool:
"""Whether a row beside this heading already reads as the name the fold would produce.
The folded row joins the siblings of the heading it replaces, and a browser row is addressed by
the names leading to it, so a heading whose fold would repeat a name beside it stays as it is.
+ The heading's parent is passed in, since the caller establishes it before a fold is considered.
"""
- return any(sibling.name == name for sibling in node.parent.children if sibling is not node)
+ return any(sibling.name == name for sibling in parent.children if sibling is not node)
def _fold_into_child(node: TreeNode) -> None:
diff --git a/src/sampletones_application/logic/reconstruction/browser/tree/configurations/grouping.py b/src/sampletones_application/logic/reconstruction/browser/tree/configurations/grouping.py
index a214fe22d..d249ec17e 100644
--- a/src/sampletones_application/logic/reconstruction/browser/tree/configurations/grouping.py
+++ b/src/sampletones_application/logic/reconstruction/browser/tree/configurations/grouping.py
@@ -1,6 +1,6 @@
from sampletones_application.logic.reconstruction.browser.tree.configurations.naming import (
assign_display_names,
- disambiguate_generator_siblings,
+ disambiguate_channel_siblings,
)
from sampletones_application.logic.reconstruction.browser.tree.containers import (
find_or_create_config_group,
@@ -32,7 +32,7 @@ def organize_top_level_config_directories(branch: TreeNode) -> None:
case FileSystemNode() if child.node_type == NodeType.DIRECTORY:
assign_display_names(child)
- disambiguate_generator_siblings(branch)
+ disambiguate_channel_siblings(branch)
def _attach_config_directory_under_groups(
diff --git a/src/sampletones_application/logic/reconstruction/browser/tree/configurations/naming.py b/src/sampletones_application/logic/reconstruction/browser/tree/configurations/naming.py
index aac9cec3c..59ef9f26f 100644
--- a/src/sampletones_application/logic/reconstruction/browser/tree/configurations/naming.py
+++ b/src/sampletones_application/logic/reconstruction/browser/tree/configurations/naming.py
@@ -4,7 +4,7 @@
from sampletones_core.structures.tree import ConfigNode, NodeType, TreeNode
-def disambiguate_generator_siblings(node: TreeNode) -> None:
+def disambiguate_channel_siblings(node: TreeNode) -> None:
"""Appends a short config hash to generator directories sharing a name under one method group."""
if node.node_type == NodeType.GROUP:
_rename_config_directories(
@@ -12,7 +12,7 @@ def disambiguate_generator_siblings(node: TreeNode) -> None:
)
for child in node.children:
- disambiguate_generator_siblings(child)
+ disambiguate_channel_siblings(child)
def assign_display_names(node: TreeNode) -> None:
diff --git a/src/sampletones_application/logic/reconstruction/browser/tree/samples/branch.py b/src/sampletones_application/logic/reconstruction/browser/tree/samples/branch.py
index 7ae3c7741..8db01c69f 100644
--- a/src/sampletones_application/logic/reconstruction/browser/tree/samples/branch.py
+++ b/src/sampletones_application/logic/reconstruction/browser/tree/samples/branch.py
@@ -21,7 +21,7 @@ def build_sample_branch(
"""Builds the branch listing each source audio with the configurations that reconstructed it.
Every top-level configuration directory contributes its reconstructions under the source folders
- they mirror, so one audio gathers its variants and each variant is labelled by its configuration.
+ they mirror, so one audio gathers its variants and each variant is labeled by its configuration.
"""
branch = TreeNode(name, node_type=NodeType.GROUP, parent=parent)
variants_by_source = collect_variants(scan)
diff --git a/src/sampletones_application/logic/reconstruction/data.py b/src/sampletones_application/logic/reconstruction/data.py
index e74ddf556..d45ff5dd0 100644
--- a/src/sampletones_application/logic/reconstruction/data.py
+++ b/src/sampletones_application/logic/reconstruction/data.py
@@ -1,15 +1,22 @@
from dataclasses import dataclass, replace
+from functools import cached_property
from pathlib import Path
-from typing import List, Optional, Self
+from typing import Dict, List, Optional, Self, Tuple
import numpy as np
from sampletones_application.logic.reconstruction.feature import FeatureData
from sampletones_application.view_model.shared.waveform_data import WaveformData
-from sampletones_core.audio import load_audio
+from sampletones_core.audio import load_stems, mix
from sampletones_core.configs import Config
-from sampletones_core.constants.enums import GeneratorName
+from sampletones_core.constants.enums import ChannelName
from sampletones_core.reconstructions import Reconstruction
+from sampletones_core.reconstructions.naming.derive import derive_name
+from sampletones_core.reconstructions.reconstruction.stems.data import StemsData
+from sampletones_core.reconstructions.reconstruction.stems.filter import (
+ filter_approximations,
+)
+from sampletones_core.reconstructions.reconstruction.stems.selection import StemSelection
from sampletones_shared.logger import logger
@@ -18,7 +25,7 @@ class ReconstructionData:
name: str
config: Config
reconstruction: Reconstruction
- original_audio: Optional[np.ndarray]
+ stem_audios: Tuple[np.ndarray, ...]
feature_data: FeatureData
filepath: Optional[Path]
@@ -52,8 +59,8 @@ def detached_copy(self, filepath: Path) -> Self:
Save As writes the reconstruction to its own file and adopts this copy as the open
document. The copy owns a fresh reconstruction object, so a document that was a project
sample becomes a standalone entity: later edits reach only the saved file, leaving the
- project's sample unchanged. The already-loaded original audio is reused, since the copy
- shares the same source.
+ project's sample unchanged. The copy shares the loaded recordings, so the original
+ audio carries over without a reload.
"""
reconstruction = self.reconstruction.model_copy(deep=True)
return replace(
@@ -68,17 +75,31 @@ def detached_copy(self, filepath: Path) -> Self:
def with_reconstruction(self, reconstruction: Reconstruction) -> Self:
"""Rebinds this data to an edited reconstruction, keeping name and origin.
- A regeneration produces a fresh reconstruction object; the display name,
- file location and source audio are unchanged, so only the reconstruction
- and its derived features are refreshed.
+ An edit produces a fresh reconstruction object; the display name and file location
+ stand, so the reconstruction, its derived features and the recordings its entries
+ hold are what the rebind refreshes.
"""
return replace(
self,
reconstruction=reconstruction,
config=reconstruction.config,
feature_data=FeatureData.load(reconstruction),
+ stem_audios=self._recordings_for(reconstruction),
)
+ def _recordings_for(self, reconstruction: Reconstruction) -> Tuple[np.ndarray, ...]:
+ """The loaded recordings, each following the entry it was loaded for.
+
+ A recording belongs to the entry standing at its position, so an entry the edit keeps
+ carries its audio to the position it now holds and an entry taken out releases it.
+ Recordings the load left out stay out.
+ """
+ if not self.stem_audios:
+ return ()
+
+ positions = {entry.id: index for index, entry in enumerate(self.reconstruction.stems_data.config.entries)}
+ return tuple(self.stem_audios[positions[entry.id]] for entry in reconstruction.stems_data.config.entries)
+
@classmethod
def _assemble(
cls,
@@ -87,13 +108,13 @@ def _assemble(
filepath: Optional[Path],
name: str,
) -> Self:
- original_audio = cls._load_original_audio(reconstruction)
+ stem_audios = cls._load_stem_audios(reconstruction)
feature_data = FeatureData.load(reconstruction)
return cls(
config=reconstruction.config,
reconstruction=reconstruction,
- original_audio=original_audio,
+ stem_audios=stem_audios,
feature_data=feature_data,
filepath=filepath,
name=name,
@@ -103,47 +124,141 @@ def _assemble(
def _derive_name(reconstruction: Reconstruction, filepath: Path) -> str:
"""Names the document after its source audio when present, otherwise after the file.
- A file-backed reconstruction keeps the audio's name for display and export; a detached
+ A file-backed reconstruction keeps the audio's name for display and export; several
+ source paths (stems) name the document through the source-naming rules, and a detached
reconstruction (no source audio) falls back to the ``.stn`` filename.
"""
- audio_filepath = reconstruction.audio_filepath
- return audio_filepath.stem if audio_filepath is not None else filepath.stem
+ source_paths = reconstruction.audio_filepath
+ if source_paths:
+ return derive_name(source_paths)
+
+ return filepath.stem
@staticmethod
- def _load_original_audio(
+ def _load_stem_audios(
reconstruction: Reconstruction,
- ) -> Optional[np.ndarray]:
- """Loads the source audio, yielding ``None`` when no usable original exists.
+ ) -> Tuple[np.ndarray, ...]:
+ """Loads the recorded source, one recording per path, in path order.
+
+ The set is loaded together, so every recording carries the level it holds in the
+ mix and one heard on its own sounds at that level.
- A reconstruction detached from its origin (a project sample) records no source path, and a
- file-backed reconstruction may point at audio absent or unreadable on this machine. Both
- cases yield ``None``; the approximation then stands on its own in playback and the display.
+ A reconstruction detached from its origin (a project sample) records no source
+ path, and a file-backed reconstruction may point at audio absent or unreadable on
+ this machine. One unreadable stem costs the whole original, so the recordings
+ come back as one empty tuple in either case; the approximation then stands on its
+ own in playback and the display.
"""
- audio_filepath = reconstruction.audio_filepath
- if audio_filepath is None:
- return None
+ source_paths = reconstruction.audio_filepath
+ if not source_paths:
+ return ()
config = reconstruction.config
try:
- return load_audio(
- path=audio_filepath,
+ return load_stems(
+ source_paths,
target_sample_rate=config.library.sample_rate,
normalize=config.general.normalize,
quantize=config.general.quantize,
+ quantization_levels=config.general.quantization_levels,
)
- except (FileNotFoundError, IsADirectoryError, PermissionError, OSError):
- logger.warning(f"Could not load original audio from '{audio_filepath}'. The original is unavailable")
- return None
+ except (FileNotFoundError, IsADirectoryError, PermissionError, OSError) as error:
+ logger.warning(f"Could not load the original audio: {error}. The original is unavailable")
+ return ()
+
+ @cached_property
+ def original_audio(self) -> Optional[np.ndarray]:
+ """The recorded source mixed into one waveform, ``None`` while no source loads."""
+ return mix(list(self.stem_audios)) if self.stem_audios else None
+
+ @cached_property
+ def _stem_recording_indexes(self) -> Dict[int, int]:
+ """Maps each stem id to the index of its recording in ``stem_audios``.
+
+ The entries' ids map to their recordings in entry order, and source audio absent
+ or unreadable maps nothing.
+ """
+ if not self.stem_audios:
+ return {}
+
+ stems_data = self.reconstruction.stems_data
+ return {entry.id: index for index, entry in enumerate(stems_data.config.entries)}
+
+ def original_mix_for(self, selection: StemSelection) -> np.ndarray:
+ """The original audio of the stems heard anywhere, silence once none are."""
+ selected_stem_ids = selection.any_channel()
+ indexes = self._stem_recording_indexes
+ recordings = [self.stem_audios[index] for stem_id, index in indexes.items() if stem_id in selected_stem_ids]
+ if not recordings:
+ return np.zeros_like(self.reconstruction.approximation)
- def waveform_data(self) -> WaveformData:
- """Projects the slice of this data the waveform display renders."""
+ return mix(recordings)
+
+ def waveform_data(
+ self,
+ selection: Optional[StemSelection] = None,
+ ) -> WaveformData:
+ """Projects the slice of this data the waveform display renders.
+
+ With a stems selection, the projection carries the frames each channel's selected
+ stems own and the mix of the recordings heard anywhere, so the waveform answers
+ exactly what plays.
+ """
+ if selection is None:
+ return self._unfiltered_waveform()
+
+ return self._filtered_waveform(
+ selection,
+ self.reconstruction.stems_data,
+ )
+
+ def _unfiltered_waveform(self) -> WaveformData:
+ """The whole document: every channel's stored approximation and the full original."""
+ return self._waveform_data(
+ self.original_audio,
+ dict(self.reconstruction.approximations),
+ self.reconstruction.approximation,
+ )
+
+ def _filtered_waveform(
+ self,
+ selection: StemSelection,
+ stems_data: StemsData,
+ ) -> WaveformData:
+ """The selected stems' frames and their original mix, in the unfiltered shape."""
+ approximations = filter_approximations(
+ stems_data,
+ self.reconstruction.approximations,
+ selection,
+ self.reconstruction.config.frame_length,
+ )
+ return self._waveform_data(
+ self.original_mix_for(selection),
+ approximations,
+ mix(list(approximations.values())),
+ )
+
+ def _waveform_data(
+ self,
+ original_audio: Optional[np.ndarray],
+ approximations: Dict[ChannelName, np.ndarray],
+ approximation: np.ndarray,
+ ) -> WaveformData:
return WaveformData(
- original_audio=self.original_audio,
- approximation=self.reconstruction.approximation,
- approximations=dict(self.reconstruction.approximations),
+ original_audio=original_audio,
+ approximation=approximation,
+ approximations=approximations,
coefficient=self.reconstruction.coefficient,
frame_length=self.reconstruction.config.frame_length,
)
- def get_partials(self, generator_names: List[GeneratorName]) -> np.ndarray:
- return self.waveform_data().partials(generator_names)
+ def get_partials(self, channel_names: List[ChannelName]) -> np.ndarray:
+ return self.waveform_data().partials(channel_names)
+
+ def partials_for(
+ self,
+ channel_names: List[ChannelName],
+ selection: StemSelection,
+ ) -> np.ndarray:
+ """Sums the selected channels with the unselected stems' frames silenced."""
+ return self.waveform_data(selection).partials(channel_names)
diff --git a/src/sampletones_application/logic/reconstruction/edit.py b/src/sampletones_application/logic/reconstruction/edit.py
new file mode 100644
index 000000000..802762777
--- /dev/null
+++ b/src/sampletones_application/logic/reconstruction/edit.py
@@ -0,0 +1,38 @@
+from dataclasses import dataclass
+from typing import Optional, TypeAlias, Union
+
+from sampletones_application.logic.history.transaction import CoalesceKey
+from sampletones_core.constants.enums import ChannelName, FeatureKey
+from sampletones_core.reconstructions import Reconstruction
+
+
+@dataclass(frozen=True)
+class ChannelEdit:
+ """A regenerated instrument paired with the channel and feature the reader moved.
+
+ Carrying the request context alongside the fresh reconstruction lets the project history
+ record which channel and feature an edit touched.
+ """
+
+ reconstruction: Reconstruction
+ channel_name: ChannelName
+ feature_key: FeatureKey
+
+ def coalesce_key(self, voice_id: str) -> Optional[CoalesceKey]:
+ """Consecutive edits of one sample run together, so a graph movement records one entry."""
+ return (voice_id,)
+
+
+@dataclass(frozen=True)
+class StemRemoval:
+ """A recording taken out of the reconstruction, named as the history reports it."""
+
+ reconstruction: Reconstruction
+ stem_name: str
+
+ def coalesce_key(self, _voice_id: str) -> Optional[CoalesceKey]:
+ """Each removal stands on its own, so one undo puts one recording back."""
+ return None
+
+
+ReconstructionEdit: TypeAlias = Union[ChannelEdit, StemRemoval]
diff --git a/src/sampletones_application/logic/reconstruction/editing.py b/src/sampletones_application/logic/reconstruction/editing.py
new file mode 100644
index 000000000..699e7f20c
--- /dev/null
+++ b/src/sampletones_application/logic/reconstruction/editing.py
@@ -0,0 +1,64 @@
+from dataclasses import dataclass
+from typing import Dict, Optional, Protocol, Union
+
+from sampletones_core.constants.enums import ChannelName, FeatureKey
+from sampletones_core.exporters import Features
+from sampletones_core.features.envelope import Envelope
+from sampletones_core.project.voices.instrument import Instrument
+
+
+@dataclass(frozen=True)
+class ReconstructionEdit:
+ """The channels of a loaded reconstruction, each with the envelopes it carries."""
+
+ channels: Dict[ChannelName, Features]
+
+
+@dataclass(frozen=True)
+class InstrumentEdit:
+ """The one envelope set an instrument carries, as the panel has it in front of a reader.
+
+ The pitch an instrument's arpeggio is measured from travels inside :attr:`features`, where
+ an export reads it; a row states the note the voice sounds at, so nothing here edits it.
+
+ Attributes:
+ voice_id: The instrument an edit is written back into.
+ name: The name the panel titles it by.
+ features: The envelopes, read as the channel offering every dimension an instrument writes.
+ """
+
+ voice_id: str
+ name: str
+ features: Features
+
+
+EditedVoice = Union[ReconstructionEdit, InstrumentEdit]
+
+
+class InstrumentEditingProtocol(Protocol):
+ """Where the instruments panel's envelopes come from, and where an edit to an instrument goes.
+
+ The panel edits one voice at a time — the channels of a loaded reconstruction, or an instrument's
+ own set — so it asks what is in front of it and renders whichever answer comes back. A
+ reconstruction's envelopes travel back out through the regeneration service; an instrument stands on
+ no audio, so its edits are written here.
+ """
+
+ def edited_instrument(self) -> Optional[EditedVoice]:
+ """What the panel is editing, or ``None`` while it holds nothing."""
+
+ def write_envelope(self, feature_key: FeatureKey, envelope: Envelope[int]) -> None:
+ """Writes one dimension of the instrument in front of the panel."""
+
+
+class InstrumentAuditionProtocol(Protocol):
+ """Where the voice an audition sounds comes from.
+
+ An audition plays the instrument the tab is showing, at the note a key names, so it reads the
+ voice whole rather than the envelopes a panel draws: the frames it sounds are made from the
+ envelopes and the pitch they are measured against together.
+ """
+
+ @property
+ def instrument(self) -> Optional[Instrument]:
+ """The instrument in front of the tab, or ``None`` while it shows a recording or nothing."""
diff --git a/src/sampletones_application/logic/reconstruction/editor.py b/src/sampletones_application/logic/reconstruction/editor.py
new file mode 100644
index 000000000..fb593d70c
--- /dev/null
+++ b/src/sampletones_application/logic/reconstruction/editor.py
@@ -0,0 +1,91 @@
+from typing import Callable, Optional
+
+from sampletones_application.logic.history.action import HistoryAction
+from sampletones_application.logic.history.manager import HistoryManager
+from sampletones_application.logic.project.controller import ProjectController
+from sampletones_application.logic.reconstruction.editing import (
+ EditedVoice,
+ InstrumentEdit,
+ ReconstructionEdit,
+)
+from sampletones_application.logic.reconstruction.manager import ReconstructionManager
+from sampletones_application.view_model.shared.history import HistoryDetail
+from sampletones_core.constants.enums import FeatureKey
+from sampletones_core.features.envelope import Envelope
+from sampletones_core.project.voices.instrument import Instrument
+
+InstrumentEditDetail = Callable[[str, FeatureKey], HistoryDetail]
+
+
+class InstrumentEditor:
+ """Which voice the Reconstructions tab has in front of it, and where an edit to it goes.
+
+ The tab shows one voice at a time: a reconstruction, whose waveform and stems the rest of the
+ tab draws, or an instrument, which has none of those and is its envelopes alone. Opening one puts
+ the other away, so the cards beside the instruments panel always describe what it is editing.
+ """
+
+ def __init__(
+ self,
+ reconstruction_manager: ReconstructionManager,
+ project_controller: ProjectController,
+ history: HistoryManager,
+ instrument_edit_detail: InstrumentEditDetail,
+ ) -> None:
+ self._reconstruction_manager = reconstruction_manager
+ self._controller = project_controller
+ self._history = history
+ self._instrument_edit_detail = instrument_edit_detail
+ self._voice_id: Optional[str] = None
+
+ def edit_instrument(self, voice_id: str) -> None:
+ """Puts an instrument in front of the tab, closing whatever reconstruction it held."""
+ self._voice_id = voice_id
+ self._reconstruction_manager.close_reconstruction()
+
+ def release_instrument(self) -> None:
+ """Lets go of the instrument, which is what opening a reconstruction does."""
+ self._voice_id = None
+
+ @property
+ def instrument(self) -> Optional[Instrument]:
+ """The instrument in front of the tab, or ``None`` where it holds a reconstruction or nothing."""
+ if self._voice_id is None:
+ return None
+
+ voice = self._controller.project.voices.get(self._voice_id)
+ return voice if isinstance(voice, Instrument) else None
+
+ def edited_instrument(self) -> Optional[EditedVoice]:
+ """What the instruments panel is editing, or ``None`` while it holds nothing."""
+ instrument = self.instrument
+ if instrument is not None:
+ return InstrumentEdit(
+ voice_id=instrument.id,
+ name=instrument.name,
+ features=instrument.instrument_features(),
+ )
+
+ feature_data = self._reconstruction_manager.current_features
+ return None if feature_data is None else ReconstructionEdit(channels=feature_data.channels)
+
+ def write_envelope(self, feature_key: FeatureKey, envelope: Envelope[int]) -> None:
+ """Writes one dimension of the instrument in front of the tab, as one history entry.
+
+ The entry names the voice and the dimension it moved, and consecutive writes of that same
+ pair run together, so a bar dragged across a plot is undone in one step rather than in one
+ step per value it passed through.
+
+ Raises:
+ RuntimeError: If the tab holds no instrument to write into.
+ """
+ instrument = self.instrument
+ if instrument is None:
+ raise RuntimeError("The tab holds no instrument to write an envelope into")
+
+ with self._history.transaction(
+ HistoryAction.EDIT_INSTRUMENT,
+ detail=self._instrument_edit_detail(instrument.id, feature_key),
+ coalesce=(instrument.id, feature_key),
+ ):
+ self._controller.set_instrument_envelope(instrument.id, feature_key, envelope)
diff --git a/src/sampletones_application/logic/reconstruction/feature.py b/src/sampletones_application/logic/reconstruction/feature.py
index a6a653f61..8497fa32b 100644
--- a/src/sampletones_application/logic/reconstruction/feature.py
+++ b/src/sampletones_application/logic/reconstruction/feature.py
@@ -1,11 +1,9 @@
from __future__ import annotations
from dataclasses import dataclass
-from typing import Dict, Optional, cast
+from typing import Dict
-import numpy as np
-
-from sampletones_core.constants.enums import FeatureKey, GeneratorName
+from sampletones_core.constants.enums import ChannelName
from sampletones_core.exporters import Features
from sampletones_core.reconstructions import Reconstruction
@@ -18,27 +16,23 @@ class FeatureData:
for any of them and :attr:`Features.has_frames` says which ones play.
"""
- generators: Dict[GeneratorName, Features]
+ channels: Dict[ChannelName, Features]
- def __getitem__(self, generator_name: GeneratorName) -> Features:
- return self.generators[generator_name]
+ def __getitem__(self, channel_name: ChannelName) -> Features:
+ return self.channels[channel_name]
@classmethod
def load(cls, reconstruction: Reconstruction) -> FeatureData:
- exported_features = reconstruction.export()
-
- generators = {}
- for generator_name_str, features in exported_features.items():
- generator_name = GeneratorName(generator_name_str)
- feature = Features(
- initial_pitch=cast(int, features.get(FeatureKey.INITIAL_PITCH)),
- volume=cast(np.ndarray, features.get(FeatureKey.VOLUME)),
- arpeggio=cast(np.ndarray, features.get(FeatureKey.ARPEGGIO)),
- pitch=cast(Optional[np.ndarray], features.get(FeatureKey.PITCH)),
- hi_pitch=cast(Optional[np.ndarray], features.get(FeatureKey.HI_PITCH)),
- duty_cycle=cast(Optional[np.ndarray], features.get(FeatureKey.DUTY_CYCLE)),
- )
-
- generators[generator_name] = feature
-
- return cls(generators=generators)
+ """The envelopes each of a reconstruction's channels plays, keyed by channel.
+
+ Args:
+ reconstruction: The reconstruction being read.
+
+ Returns:
+ FeatureData: One entry per channel the reconstruction exports.
+ """
+ return cls(
+ channels={
+ ChannelName(generator_name): features for generator_name, features in reconstruction.export().items()
+ }
+ )
diff --git a/src/sampletones_application/logic/reconstruction/instruments.py b/src/sampletones_application/logic/reconstruction/instruments.py
index aec69a882..f6e041abe 100644
--- a/src/sampletones_application/logic/reconstruction/instruments.py
+++ b/src/sampletones_application/logic/reconstruction/instruments.py
@@ -1,27 +1,32 @@
-from typing import Callable, Dict, FrozenSet, Optional
-
-import numpy as np
+from typing import Callable, Dict, Optional
+from sampletones_application.constants.instruments import INSTRUMENT_CHANNEL
from sampletones_application.layout.behavior.scheduling.scheduling import (
SchedulingBehavior,
)
-from sampletones_application.logic.reconstruction.manager import ReconstructionManager
+from sampletones_application.logic.reconstruction.editing import (
+ InstrumentEdit,
+ InstrumentEditingProtocol,
+ ReconstructionEdit,
+)
from sampletones_application.utils.callbacks.queue import CallbackQueue
from sampletones_application.view_model.reconstruction.instruments import (
+ InstrumentViewModel,
ReconstructionInstrumentsViewModel,
)
from sampletones_application.view_model.reconstruction.update import (
ReconstructionUpdate,
)
-from sampletones_application.view_model.shared.footprint import SampleFootprintViewModel
-from sampletones_core.constants.enums import FeatureKey, GeneratorName
-from sampletones_core.exporters import Features
+from sampletones_application.view_model.shared.footprint import VoiceFootprintViewModel
+from sampletones_core.constants.enums import ChannelName, FeatureKey
+from sampletones_core.exporters import Features, playing_channels
+from sampletones_core.features.envelope import Envelope
from sampletones_core.formats.famitracker.footprint import features_footprint
-from sampletones_core.types.feature import FeatureValue
+from sampletones_shared.types.callback import VoidCallback
from sampletones_shared.utils.callbacks import CallbackMixin
OnReconstructionInstrumentUpdatedCallback = Callable[
- [GeneratorName, Features, FeatureKey, FeatureValue],
+ [ChannelName, FeatureKey, Features],
None,
]
@@ -29,23 +34,48 @@
class ReconstructionInstrumentsLogic(CallbackMixin):
def __init__(
self,
- reconstruction_manager: ReconstructionManager,
+ editor: InstrumentEditingProtocol,
*,
scheduling: SchedulingBehavior,
) -> None:
- self.reconstruction_manager = reconstruction_manager
+ self._editor = editor
self._scheduling = scheduling
self._pending_reconstruction_update: Optional[ReconstructionUpdate] = None
self.on_view_changed: Optional[Callable[[ReconstructionInstrumentsViewModel], None]] = None
- self.on_feature_data_changed: Optional[Callable[[Optional[Dict[GeneratorName, Features]]], None]] = None
+ self.on_feature_data_changed: Optional[Callable[[Optional[Dict[ChannelName, Features]]], None]] = None
self.on_reconstruction_instrument_updated: Optional[OnReconstructionInstrumentUpdatedCallback] = None
+ self.on_display_refreshed: Optional[VoidCallback] = None
def update_display(self) -> None:
- generators = self._current_generators()
- self.call(self.on_view_changed, self._build_view_model(generators))
- self.call(self.on_feature_data_changed, generators)
+ """Renders whatever the panel has in front of it, envelopes and figures together.
+
+ The cards beside the panel describe the same voice, so the render is reported once it has
+ been made and they settle on it: an edit to an instrument redraws its waveform here.
+ """
+ self.call(self.on_view_changed, self._build_view_model(self._current_generators()))
+ self.call(self.on_feature_data_changed, self._displayed_features())
+ self.call(self.on_display_refreshed)
+
+ def _displayed_features(self) -> Optional[Dict[ChannelName, Features]]:
+ """The envelopes the panel draws: a reconstruction's channels, or an instrument's own set.
+
+ An instrument is drawn on the tab the panel shows it under, which is the channel offering every
+ dimension an instrument writes.
+ """
+ instrument = self.instrument_edit
+ if instrument is not None:
+ return self._instrument_channels(instrument)
+
+ return self._current_generators()
+
+ @staticmethod
+ def _instrument_channels(
+ instrument: InstrumentEdit,
+ ) -> Dict[ChannelName, Features]:
+ """An instrument's envelopes under the channel the panel shows it on."""
+ return {INSTRUMENT_CHANNEL: instrument.features}
def refresh_view(self) -> None:
"""Reports which channels play and the sizes they occupy, leaving the displayed envelopes as they are.
@@ -54,99 +84,142 @@ def refresh_view(self) -> None:
channels settle on it. The envelopes themselves are left to the edit that started the
regeneration, so a field the user is still typing in keeps what they wrote.
"""
- self.call(self.on_view_changed, self._build_view_model(self._current_generators()))
+ self.call(
+ self.on_view_changed,
+ self._build_view_model(self._current_generators()),
+ )
+
+ def _current_generators(self) -> Optional[Dict[ChannelName, Features]]:
+ """The channels of the reconstruction in front of the panel, where one is."""
+ match self._editor.edited_instrument():
+ case ReconstructionEdit() as edit:
+ return edit.channels
+ case _:
+ return None
- def _current_generators(self) -> Optional[Dict[GeneratorName, Features]]:
- feature_data = self.reconstruction_manager.current_features
- return None if feature_data is None else feature_data.generators
+ @property
+ def instrument_edit(self) -> Optional[InstrumentEdit]:
+ """The instrument in front of the panel, where one is."""
+ match self._editor.edited_instrument():
+ case InstrumentEdit() as edit:
+ return edit
+ case _:
+ return None
def _build_view_model(
self,
- generators: Optional[Dict[GeneratorName, Features]],
+ channels: Optional[Dict[ChannelName, Features]],
) -> ReconstructionInstrumentsViewModel:
- if generators is None:
+ instrument = self.instrument_edit
+ if instrument is not None:
+ return self._instrument_view_model(instrument)
+
+ if channels is None:
return ReconstructionInstrumentsViewModel(
reconstruction_loaded=False,
- playing_generators=frozenset(),
+ playing_channels=frozenset(),
footprint=None,
)
- playing_generators: FrozenSet[GeneratorName] = frozenset(
- generator_name for generator_name, features in generators.items() if features.has_frames
- )
return ReconstructionInstrumentsViewModel(
reconstruction_loaded=True,
- playing_generators=playing_generators,
- footprint=self._build_footprint(generators),
+ playing_channels=playing_channels(channels),
+ footprint=self._build_footprint(channels),
+ )
+
+ def _instrument_view_model(
+ self,
+ instrument: InstrumentEdit,
+ ) -> ReconstructionInstrumentsViewModel:
+ """What the panel shows of an instrument: its envelopes, its roots and what it costs.
+
+ An instrument is shown under one channel, and it plays there once its envelopes describe a
+ frame, so it stands by the way a reconstruction's silent channel does until the reader
+ writes one.
+ """
+ return ReconstructionInstrumentsViewModel(
+ reconstruction_loaded=False,
+ playing_channels=playing_channels(self._instrument_channels(instrument)),
+ footprint=VoiceFootprintViewModel.from_instrument(features_footprint(instrument.features)),
+ instrument=InstrumentViewModel(name=instrument.name),
)
def _build_footprint(
self,
- generators: Dict[GeneratorName, Features],
- ) -> SampleFootprintViewModel:
+ channels: Dict[ChannelName, Features],
+ ) -> VoiceFootprintViewModel:
"""Measures each playing channel's instrument as the size its own export writes.
- A reconstruction has no loop flag of its own — that belongs to a sample placed in a
- project — so each instrument is measured playing its envelopes once, matching what
+ Each instrument is measured at the lengths its own envelopes state, matching what
**Export instrument...** produces. A channel standing by is written nowhere, so it is
measured nowhere and the sample's total names what the export costs.
"""
- return SampleFootprintViewModel.from_footprints(
+ return VoiceFootprintViewModel.from_footprints(
{
- generator_name: features_footprint(features, loop=False)
- for generator_name, features in generators.items()
+ channel_name: features_footprint(features)
+ for channel_name, features in channels.items()
if features.has_frames
}
)
def handle_pitch_value_changed(
self,
- generator_name: GeneratorName,
+ channel_name: ChannelName,
value: int,
) -> None:
+ """Moves the pitch one channel of a reconstruction has its frames measured against.
+
+ A conversion states the value it found, and moving it rebuilds the channel's frames around
+ the new origin, so the edit travels back out through the regeneration.
+ """
+ features = self._get_features(channel_name)
self._schedule_reconstruction_update(
ReconstructionUpdate(
- generator_name,
+ channel_name,
FeatureKey.INITIAL_PITCH,
- value,
+ features.model_copy(update={"initial_pitch": value}),
)
)
- def handle_bar_point_clicked(
+ def handle_envelope_changed(
self,
- generator_name: GeneratorName,
+ channel_name: ChannelName,
feature_key: FeatureKey,
- data: np.ndarray,
+ envelope: Envelope[int],
) -> None:
- self._report_edited_size(generator_name, feature_key, data)
- self._schedule_reconstruction_update(
- ReconstructionUpdate(
- generator_name,
- feature_key,
- data,
- )
- )
+ """Takes one dimension as an edit leaves it, values and loop point together.
- def handle_raw_data_changed(
+ The panel states the whole dimension, so a bar redrawn on the plot and a sequence typed
+ into the text field arrive the same way and are written the same way.
+ """
+ if self._write_instrument_envelope(feature_key, envelope):
+ return
+
+ features = self._get_features(channel_name).with_envelope(feature_key, envelope)
+ self._report_edited_size(channel_name, features)
+ self._schedule_reconstruction_update(ReconstructionUpdate(channel_name, feature_key, features))
+
+ def _write_instrument_envelope(
self,
- generator_name: GeneratorName,
feature_key: FeatureKey,
- data: np.ndarray,
- ) -> None:
- self._report_edited_size(generator_name, feature_key, data)
- self._schedule_reconstruction_update(
- ReconstructionUpdate(
- generator_name,
- feature_key,
- data,
- )
- )
+ envelope: Envelope[int],
+ ) -> bool:
+ """Writes one dimension of the instrument in front of the panel, reporting whether it did.
+
+ An instrument stands on no audio, so an edit reaches it at once rather than through the
+ regeneration a reconstruction's envelopes go back through.
+ """
+ if self.instrument_edit is None:
+ return False
+
+ self._editor.write_envelope(feature_key, envelope)
+ self.update_display()
+ return True
def _report_edited_size(
self,
- generator_name: GeneratorName,
- feature_key: FeatureKey,
- data: np.ndarray,
+ channel_name: ChannelName,
+ features: Features,
) -> None:
"""Reports what the edited envelope costs as the edit arrives, ahead of its regeneration.
@@ -154,34 +227,15 @@ def _report_edited_size(
while the reconstruction is still being rebuilt. The regenerated instruments report again
once they land, so the figures settle on the exported form.
"""
- generators = self._current_generators()
- if generators is None:
+ channels = self._current_generators()
+ if channels is None:
return
self.call(
self.on_view_changed,
- self._build_view_model(
- self._with_edit(
- generators,
- generator_name,
- feature_key,
- data,
- )
- ),
+ self._build_view_model({**channels, channel_name: features}),
)
- def _with_edit(
- self,
- generators: Dict[GeneratorName, Features],
- generator_name: GeneratorName,
- feature_key: FeatureKey,
- data: np.ndarray,
- ) -> Dict[GeneratorName, Features]:
- """The loaded channels with one envelope replaced, leaving the loaded ones as they are."""
- edited = generators[generator_name].model_copy(deep=True)
- edited[feature_key] = data
- return {**generators, generator_name: edited}
-
def _schedule_reconstruction_update(
self,
update: ReconstructionUpdate,
@@ -204,18 +258,12 @@ def _on_reconstruction_update_scheduled(self) -> None:
if self._pending_reconstruction_update is None:
return
- generator_name, feature_key, data = self._pending_reconstruction_update
+ channel_name, feature_key, features = self._pending_reconstruction_update
self._pending_reconstruction_update = None
- self.call(
- self.on_reconstruction_instrument_updated,
- generator_name,
- self._get_features(generator_name),
- feature_key,
- data,
- )
+ self.call(self.on_reconstruction_instrument_updated, channel_name, feature_key, features)
- def _get_features(self, generator_name: GeneratorName) -> Features:
- current_features = self.reconstruction_manager.current_features
- assert current_features is not None, "Current features should not be None"
+ def _get_features(self, channel_name: ChannelName) -> Features:
+ channels = self._current_generators()
+ assert channels is not None, "A channel edit arrives only while a reconstruction is open"
- return current_features[generator_name]
+ return channels[channel_name]
diff --git a/src/sampletones_application/logic/reconstruction/manager.py b/src/sampletones_application/logic/reconstruction/manager.py
index d88078b7a..03fbd1bcf 100644
--- a/src/sampletones_application/logic/reconstruction/manager.py
+++ b/src/sampletones_application/logic/reconstruction/manager.py
@@ -1,5 +1,7 @@
+import errno
+import os
from pathlib import Path
-from typing import Optional
+from typing import Optional, Tuple
from sampletones_application.layout.behavior.scheduling.scheduling import SchedulingBehavior
from sampletones_application.logic.reconstruction.data import ReconstructionData
@@ -11,7 +13,8 @@
from sampletones_shared.types.callback import VoidCallback
from sampletones_shared.utils.callbacks import CallbackMixin
from sampletones_shared.utils.serialization import hash_model
-from sampletones_shared.utils.system.paths import open_path_in_explorer
+from sampletones_shared.utils.system.paths import first_missing
+from sampletones_shared.utils.system.reveal.selection import open_paths_in_explorer
class ReconstructionManager(CallbackMixin):
@@ -144,8 +147,8 @@ def detach_current_reconstruction(self) -> None:
name=name,
)
- def apply_regenerated(self, reconstruction: Reconstruction) -> None:
- """Adopts an edited reconstruction produced by regeneration.
+ def apply_edited(self, reconstruction: Reconstruction) -> None:
+ """Adopts a reconstruction an edit produced.
The open document rebinds to the fresh reconstruction object so the editor
and any owning project sample continue to share one identity, while the
@@ -172,14 +175,19 @@ def close_reconstruction(self) -> None:
)
def locate_original_audio(self) -> None:
- original_audio_path = self.audio_filepath
- if not original_audio_path:
+ original_audio_paths = self.source_paths
+ if not original_audio_paths:
return
- if not original_audio_path.exists():
- raise FileNotFoundError(f"Original audio file '{original_audio_path}' could not be found.")
+ missing_path = first_missing(original_audio_paths)
+ if missing_path is not None:
+ raise FileNotFoundError(
+ errno.ENOENT,
+ os.strerror(errno.ENOENT),
+ str(missing_path),
+ )
- open_path_in_explorer(original_audio_path)
+ open_paths_in_explorer(original_audio_paths)
@property
def current_reconstruction(self) -> Optional[ReconstructionData]:
@@ -208,8 +216,8 @@ def is_file_backed(self) -> bool:
return self.filepath is not None
@property
- def audio_filepath(self) -> Optional[Path]:
+ def source_paths(self) -> Tuple[Path, ...]:
if self._current_reconstruction is None:
- return None
+ return ()
return self._current_reconstruction.reconstruction.audio_filepath
diff --git a/src/sampletones_application/logic/reconstruction/reconstruction.py b/src/sampletones_application/logic/reconstruction/reconstruction.py
index 39e0a7353..910b23939 100644
--- a/src/sampletones_application/logic/reconstruction/reconstruction.py
+++ b/src/sampletones_application/logic/reconstruction/reconstruction.py
@@ -1,30 +1,72 @@
from pathlib import Path
-from typing import Callable, Dict, FrozenSet, List, Optional, Protocol, Tuple
+from typing import (
+ Callable,
+ Dict,
+ Final,
+ FrozenSet,
+ List,
+ Optional,
+ Protocol,
+ Set,
+ Tuple,
+)
import numpy as np
from sampletones_application.config.managers.session import SessionManager
+from sampletones_application.logic.export.instrument.source import ExportableInstrument
from sampletones_application.logic.reconstruction.data import ReconstructionData
from sampletones_application.logic.reconstruction.manager import ReconstructionManager
-from sampletones_application.view_model.reconstruction.reconstruction import (
- ReconstructionPathState,
+from sampletones_application.view_model.reconstruction.paths.path import (
ReconstructionPathViewModel,
+)
+from sampletones_application.view_model.reconstruction.paths.state import (
+ ReconstructionPathState,
+)
+from sampletones_application.view_model.reconstruction.reconstruction import (
ReconstructionViewModel,
)
+from sampletones_application.view_model.reconstruction.stems import (
+ ReconstructionStemsViewModel,
+)
from sampletones_application.view_model.shared.audio_data import AudioData
+from sampletones_application.view_model.shared.stems import (
+ StemRowViewModel,
+ StemsListViewModel,
+)
from sampletones_application.view_model.shared.waveform_data import WaveformData
-from sampletones_core.constants.enums import AudioSourceType, GeneratorName
+from sampletones_core.configs.library import InstructionsLibraryConfig
+from sampletones_core.constants.enums import AudioSourceType, ChannelName
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_core.exports.backend import ExportBackend
+from sampletones_core.exports.format import ExportFormat
+from sampletones_core.exports.request import (
+ InstrumentExport,
+ InstrumentSource,
+ SampleExport,
+)
+from sampletones_core.exports.scope import ExportScope
+from sampletones_core.reconstructions.reconstruction.stems.selection import (
+ StemSelection,
+)
from sampletones_shared.logger import logger
+from sampletones_shared.music import Tuning
from sampletones_shared.types.callback import PathCallback, VoidCallback
from sampletones_shared.utils.callbacks import CallbackMixin
-from sampletones_shared.utils.system.paths import get_filename, open_path_in_explorer
+from sampletones_shared.utils.system.paths import (
+ first_missing,
+ get_filename,
+ open_path_in_explorer,
+)
+
+EMPTY_STEMS_LIST: Final[StemsListViewModel] = StemsListViewModel(
+ rows=(),
+ channels_in_play=(),
+ muted_channels=frozenset(),
+ live=True,
+ collapse_levels=False,
+)
class ExportServiceProtocol(Protocol):
@@ -44,14 +86,14 @@ def export_wav(
def export_instrument(
self,
destination: Path,
- backend: TrackerBackend,
+ backend: ExportBackend,
request: InstrumentExport,
) -> None: ...
def export_sample(
self,
destination: Path,
- backend: TrackerBackend,
+ backend: ExportBackend,
request: SampleExport,
) -> None: ...
@@ -62,48 +104,56 @@ def __init__(
session_manager: SessionManager,
reconstruction_manager: ReconstructionManager,
export_service: ExportServiceProtocol,
- tracker_backends: Dict[TrackerFormat, TrackerBackend],
+ export_backends: Dict[ExportFormat, ExportBackend],
) -> None:
self._session_manager = session_manager
self._reconstruction_manager = reconstruction_manager
self._export_service = export_service
- self._tracker_backends = tracker_backends
+ self._export_backends = export_backends
self._current_audio_source: AudioSourceType = AudioSourceType.RECONSTRUCTION
- self._playing_generators: FrozenSet[GeneratorName] = frozenset()
- self._selected_generators: List[GeneratorName] = []
+ self._playing_channels: FrozenSet[ChannelName] = frozenset()
+ self._selected_channels: List[ChannelName] = []
+ self._offered_stem_channels: Dict[int, FrozenSet[ChannelName]] = {}
+ self._stem_channels: Dict[int, FrozenSet[ChannelName]] = {}
self.on_view_changed: Optional[Callable[[ReconstructionViewModel], None]] = None
self.on_audio_data_changed: Optional[Callable[[Optional[AudioData]], None]] = None
- self.on_waveform_load_changed: Optional[Callable[[WaveformData, List[GeneratorName]], None]] = None
- self.on_waveform_update_changed: Optional[Callable[[WaveformData, List[GeneratorName]], None]] = None
+ self.on_waveform_load_changed: Optional[Callable[[WaveformData, List[ChannelName]], None]] = None
+ self.on_waveform_update_changed: Optional[Callable[[WaveformData, List[ChannelName]], None]] = None
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, GeneratorName], None]] = None
- self.on_open_export_instruments_dialog: Optional[Callable[[str, str, TrackerFormat], None]] = None
+ self.on_open_export_instruments_dialog: Optional[Callable[[str, str, ExportFormat], None]] = None
self.on_open_export_wav_dialog: Optional[Callable[[str, str], None]] = None
self.on_locate_audio_not_found: Optional[PathCallback] = None
+ self.on_stems_view_changed: Optional[Callable[[ReconstructionStemsViewModel], None]] = None
def display_reconstruction(self) -> None:
reconstruction_data = self._reconstruction_data
if not reconstruction_data:
return
- self._playing_generators = frozenset(reconstruction_data.reconstruction.playing_generators)
- self._selected_generators = self._in_channel_order(self._playing_generators)
+ self._playing_channels = frozenset(reconstruction_data.reconstruction.playing_channels)
+ self._selected_channels = self._in_channel_order(self._playing_channels)
+ self._offered_stem_channels = self._offered_channels(reconstruction_data)
+ self._stem_channels = dict(self._offered_stem_channels)
view_model = self._build_view_model(reconstruction_data)
if not view_model.audio_source_enabled:
self._current_audio_source = AudioSourceType.RECONSTRUCTION
self.call(self.on_view_changed, view_model)
+ self.call(
+ self.on_stems_view_changed,
+ self._build_stems_view_model(reconstruction_data),
+ )
self.call(self.on_waveform_source_changed, self._current_audio_source)
self.call(
self.on_waveform_load_changed,
- reconstruction_data.waveform_data(),
- self._selected_generators,
+ reconstruction_data.waveform_data(self._stem_selection),
+ self._selected_channels,
)
self._emit_audio_data()
@@ -112,20 +162,25 @@ def update_reconstruction(self) -> None:
if not reconstruction_data:
return
- self._adopt_playing_generators(frozenset(reconstruction_data.reconstruction.playing_generators))
+ self._adopt_playing_channels(frozenset(reconstruction_data.reconstruction.playing_channels))
+ self._adopt_stem_channels(self._offered_channels(reconstruction_data))
self.call(self.on_view_changed, self._build_view_model(reconstruction_data))
+ self.call(
+ self.on_stems_view_changed,
+ self._build_stems_view_model(reconstruction_data),
+ )
self.call(
self.on_waveform_update_changed,
- reconstruction_data.waveform_data(),
- self._selected_generators,
+ reconstruction_data.waveform_data(self._stem_selection),
+ self._selected_channels,
)
if self._current_audio_source != AudioSourceType.ORIGINAL:
self._emit_audio_data()
- def _adopt_playing_generators(
+ def _adopt_playing_channels(
self,
- playing_generators: FrozenSet[GeneratorName],
+ playing_channels: FrozenSet[ChannelName],
) -> None:
"""Carries the reader's choice of channels across an edit.
@@ -133,15 +188,13 @@ def _adopt_playing_generators(
whatever the reader chose for it, and one gaining its first frame joins the waveform,
so the checkboxes report what plays while a deliberate choice survives.
"""
- selected = (set(self._selected_generators) & playing_generators) | (
- playing_generators - self._playing_generators
- )
- self._playing_generators = playing_generators
- self._selected_generators = self._in_channel_order(frozenset(selected))
+ selected = (set(self._selected_channels) & playing_channels) | (playing_channels - self._playing_channels)
+ self._playing_channels = playing_channels
+ self._selected_channels = self._in_channel_order(frozenset(selected))
@staticmethod
- def _in_channel_order(generators: FrozenSet[GeneratorName]) -> List[GeneratorName]:
- return [generator_name for generator_name in GeneratorName.items() if generator_name in generators]
+ def _in_channel_order(channels: FrozenSet[ChannelName]) -> List[ChannelName]:
+ return [channel_name for channel_name in ChannelName.items() if channel_name in channels]
def _build_view_model(
self,
@@ -150,30 +203,41 @@ def _build_view_model(
reconstruction_file, original_audio = self._build_path_view_models(reconstruction_data)
return ReconstructionViewModel(
reconstruction_loaded=True,
- playing_generators=self._playing_generators,
- selected_generators=frozenset(self._selected_generators),
+ playing_channels=self._playing_channels,
+ selected_channels=frozenset(self._selected_channels),
reconstruction_file=reconstruction_file,
original_audio=original_audio,
+ nes_frequency=reconstruction_data.config.nes_frequency,
)
def close_reconstruction(self) -> None:
self._current_audio_source = AudioSourceType.RECONSTRUCTION
- self._playing_generators = frozenset()
- self._selected_generators = []
+ self._playing_channels = frozenset()
+ self._selected_channels = []
+ self._offered_stem_channels = {}
+ self._stem_channels = {}
self.call(self.on_audio_data_changed, None)
self.call(self.on_waveform_cleared)
+ self.call(
+ self.on_stems_view_changed,
+ ReconstructionStemsViewModel(
+ reconstruction_loaded=False,
+ stems=EMPTY_STEMS_LIST,
+ ),
+ )
empty_path = ReconstructionPathViewModel(
state=ReconstructionPathState.EMPTY,
- path="",
+ paths=(),
)
self.call(
self.on_view_changed,
ReconstructionViewModel(
reconstruction_loaded=False,
- playing_generators=frozenset(),
- selected_generators=frozenset(),
+ playing_channels=frozenset(),
+ selected_channels=frozenset(),
reconstruction_file=empty_path,
original_audio=empty_path,
+ nes_frequency=None,
),
)
@@ -182,74 +246,209 @@ def set_audio_source(self, audio_source: AudioSourceType) -> None:
self._emit_audio_data()
self.call(self.on_waveform_source_changed, audio_source)
- def set_selected_generators(self, generators: List[GeneratorName]) -> None:
- self._selected_generators = generators
+ def set_selected_channels(self, channels: List[ChannelName]) -> None:
+ """Adopts the reader's channel choice, which the stems list reports as muted columns."""
+ self._selected_channels = channels
+ reconstruction_data = self._reconstruction_data
+ if not reconstruction_data:
+ return
+
+ self.call(
+ self.on_stems_view_changed,
+ self._build_stems_view_model(reconstruction_data),
+ )
+ self.call(
+ self.on_waveform_load_changed,
+ reconstruction_data.waveform_data(self._stem_selection),
+ channels,
+ )
+ self._emit_audio_data()
+
+ def set_stem_channels(
+ self,
+ stem_id: int,
+ channels: FrozenSet[ChannelName],
+ ) -> None:
+ """Adopts the channels one recording is heard on and re-answers playback and the waveform.
+
+ The choice is listening state, so it filters what plays and what the waveform
+ shows without touching the document.
+ """
+ self._stem_channels[stem_id] = channels
reconstruction_data = self._reconstruction_data
if not reconstruction_data:
return
+ self.call(
+ self.on_stems_view_changed,
+ self._build_stems_view_model(reconstruction_data),
+ )
self.call(
self.on_waveform_load_changed,
- reconstruction_data.waveform_data(),
- generators,
+ reconstruction_data.waveform_data(self._stem_selection),
+ self._selected_channels,
)
self._emit_audio_data()
- def request_export_instrument_dialog(
+ def _adopt_stem_channels(
self,
- generator_name: GeneratorName,
+ offered: Dict[int, FrozenSet[ChannelName]],
) -> None:
- """Asks for the destination one generator slice is written to.
+ """Carries the reader's per-channel stem choice across an edit.
+
+ A channel a stem keeps holding frames on keeps whatever the reader chose for it, and
+ one the stem reaches for the first time joins ticked, so a deliberate choice survives
+ while the new content is heard. A stem appearing for the first time offers everything
+ it holds, which is the same rule read against the nothing it offered before.
+ """
+ self._stem_channels = {
+ stem_id: (self._stem_channels.get(stem_id, frozenset()) & channels)
+ | (channels - self._offered_stem_channels.get(stem_id, frozenset()))
+ for stem_id, channels in offered.items()
+ }
+ self._offered_stem_channels = offered
+
+ @property
+ def _stem_selection(self) -> StemSelection:
+ """What the reader is listening to, read the way the filter asks for it.
+
+ The card keeps the channels each recording is heard on; the filter asks each channel
+ which recordings it keeps, so the selection is that map turned around.
+ """
+ channels: Dict[ChannelName, Set[int]] = {channel_name: set() for channel_name in ChannelName.items()}
+ for stem_id, stem_channels in self._stem_channels.items():
+ for channel_name in stem_channels:
+ channels[channel_name].add(stem_id)
+
+ return StemSelection(
+ channels={channel_name: frozenset(stem_ids) for channel_name, stem_ids in channels.items()}
+ )
+
+ @staticmethod
+ def _offered_channels(
+ reconstruction_data: ReconstructionData,
+ ) -> Dict[int, FrozenSet[ChannelName]]:
+ """The channels each stem holds frames on, which is what its row offers a box for.
+
+ A stem the picker never chose on a channel contributes nothing there whatever the
+ reader ticks, so the row draws a box exactly where the choice reaches something.
+ """
+ stems_data = reconstruction_data.reconstruction.stems_data
+ offered: Dict[int, Set[ChannelName]] = {entry.id: set() for entry in stems_data.config.entries}
+ for channel_name, stem_ids in stems_data.assignments_by_channel.items():
+ for stem_id in set(stem_ids):
+ if stem_id in offered:
+ offered[stem_id].add(channel_name)
+
+ return {stem_id: frozenset(channels) for stem_id, channels in offered.items()}
+
+ def _build_stems_view_model(
+ self,
+ reconstruction_data: ReconstructionData,
+ ) -> ReconstructionStemsViewModel:
+ """The recorded assignment as the stems list draws it, banded by the picking levels."""
+ reconstruction = reconstruction_data.reconstruction
+ stems_data = reconstruction.stems_data
+ source_paths = reconstruction.audio_filepath
+ if not source_paths:
+ return ReconstructionStemsViewModel(
+ reconstruction_loaded=True,
+ stems=EMPTY_STEMS_LIST,
+ )
+
+ recordings = {entry.id: source_paths[index] for index, entry in enumerate(stems_data.config.entries)}
+ levels = stems_data.config.hierarchy.levels
+ rows = tuple(
+ StemRowViewModel(
+ key=str(stem_id),
+ path=recordings[stem_id],
+ channels=self._stem_channels.get(stem_id, frozenset()),
+ offered_channels=self._offered_stem_channels.get(stem_id, frozenset()),
+ available=recordings[stem_id].is_file(),
+ level=level_index,
+ position=position,
+ level_size=len(level),
+ level_count=len(levels),
+ )
+ for level_index, level in enumerate(levels)
+ for position, stem_id in enumerate(level)
+ )
+ channels_in_play = self._in_channel_order(
+ frozenset(channel_name for row in rows for channel_name in row.offered_channels)
+ )
+ return ReconstructionStemsViewModel(
+ reconstruction_loaded=True,
+ stems=StemsListViewModel(
+ rows=rows,
+ channels_in_play=tuple(channels_in_play),
+ muted_channels=frozenset(channels_in_play) - frozenset(self._selected_channels),
+ live=True,
+ collapse_levels=False,
+ ),
+ hierarchy_mode=stems_data.config.hierarchy.mode,
+ channel_cap=stems_data.config.channel_cap,
+ )
+
+ def exportable_instrument(
+ self,
+ channel_name: ChannelName,
+ ) -> Optional[ExportableInstrument]:
+ """The loaded reconstruction's ``channel_name`` slice, ready to be given a destination.
- 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.
+ 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.
Args:
- generator_name: The generator whose slice is written.
+ channel_name: The channel whose slice is written.
+
+ Returns:
+ Optional[ExportableInstrument]: The slice and the name to suggest for it, or ``None``
+ where that channel describes no frame and is written nowhere.
+
+ Raises:
+ AssertionError: If no reconstruction is loaded.
"""
reconstruction_data = self._reconstruction_data
if not reconstruction_data:
raise AssertionError("Expected reconstruction data to be loaded before exporting an instrument")
- if generator_name not in reconstruction_data.reconstruction.playing_generators:
- return
-
- instrument_name = self._get_instrument_name(generator_name)
- default_path = str(self._session_manager.get_instrument_path())
+ if channel_name not in reconstruction_data.reconstruction.playing_channels:
+ return None
- self.call(
- self.on_open_export_instrument_dialog,
- instrument_name,
- default_path,
- generator_name,
+ return ExportableInstrument(
+ name=self._get_instrument_name(channel_name),
+ source=InstrumentSource(
+ channel=channel_name,
+ features=reconstruction_data.feature_data[channel_name],
+ nes_frequency=self._nes_frequency(),
+ tuning=self._tuning(),
+ ),
)
def request_export_instruments_dialog(
self,
- tracker_format: TrackerFormat,
+ export_format: ExportFormat,
) -> 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.
+ The format comes from the action that was chosen, so the dialog offers that
+ format's file type alone and the suggestion already ends in its extension.
Args:
- tracker_format: The tracker the slices are written for.
+ export_format: The format the slices are written in.
"""
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)
+ extension = self._export_backends[export_format].extension(ExportScope.SAMPLE)
self.call(
self.on_open_export_instruments_dialog,
get_filename(reconstruction_data.name, extension),
default_path,
- tracker_format,
+ export_format,
)
def request_export_wav_dialog(self) -> None:
@@ -260,53 +459,27 @@ def request_export_wav_dialog(self) -> None:
default_filename = reconstruction_data.name
default_path = str(self._session_manager.get_audio_path())
- self.call(self.on_open_export_wav_dialog, default_filename, default_path)
-
- 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")
- return
-
- 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,
- self._tracker_backends[tracker_format],
- self._instrument_export(generator_name, feature, filepath.stem),
+ self.call(
+ self.on_open_export_wav_dialog,
+ default_filename,
+ default_path,
)
def handle_export_instruments_confirmed(
self,
destination: Path,
- tracker_format: TrackerFormat,
+ export_format: ExportFormat,
) -> None:
"""Writes the slice of every playing channel of the loaded reconstruction to ``destination``.
- The destination names the batch: each slice takes its generator suffix from the stem,
+ The destination names the batch: each slice takes its channel 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. A channel standing by
describes no frame and is written nowhere.
Args:
destination: The file the export was confirmed with.
- tracker_format: The tracker the slices are written for.
+ export_format: The format the slices are written in.
"""
reconstruction_data = self._reconstruction_data
if not reconstruction_data:
@@ -318,74 +491,66 @@ def handle_export_instruments_confirmed(
name=base_name,
instruments=tuple(
self._instrument_export(
- generator_name,
+ channel_name,
feature,
- instrument_slice_name(base_name, generator_name),
+ instrument_slice_name(base_name, channel_name),
)
- for generator_name, feature in reconstruction_data.feature_data.generators.items()
+ for channel_name, feature in reconstruction_data.feature_data.channels.items()
if feature.has_frames
),
nes_frequency=self._nes_frequency(),
+ tuning=self._tuning(),
)
self._session_manager.set_instrument_path(destination.parent)
self._export_service.export_sample(
destination,
- self._tracker_backends[tracker_format],
+ self._export_backends[export_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,
+ channel_name: ChannelName,
feature: Features,
name: str,
) -> InstrumentExport:
- """Packages one generator slice under ``name`` for a tracker backend.
+ """Packages one channel slice under ``name`` for an export 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.
+ A reconstruction's envelopes state no repeat of their own, so each dimension holds its
+ final value once it runs out and the trailing silence releases the note.
"""
return InstrumentExport(
name=name,
- generator=generator_name,
+ channel=channel_name,
features=feature,
- loop=False,
nes_frequency=self._nes_frequency(),
+ tuning=self._tuning(),
)
def _nes_frequency(self) -> int:
"""The rate the loaded reconstruction's envelopes advance at, in Hz."""
+ return self._library_config().nes_frequency
+
+ def _tuning(self) -> Tuning:
+ """Where concert pitch sat for the loaded reconstruction.
+
+ A backend sounding the export on its own — the console player's driver reaching pitches
+ through timer values — measures them from the tuning the reconstruction was built with,
+ which keeps what it plays in tune with the reconstruction's own approximation.
+ """
+ return self._library_config().tuning
+
+ def _library_config(self) -> InstructionsLibraryConfig:
+ """The instruction settings the loaded reconstruction was built with.
+
+ Raises:
+ AssertionError: If no reconstruction is loaded.
+ """
reconstruction_data = self._reconstruction_data
if not reconstruction_data:
raise AssertionError("Expected reconstruction data to be present")
- return reconstruction_data.config.library.nes_frequency
+ return reconstruction_data.config.library
def handle_export_wav_confirmed(self, filepath: Path) -> None:
reconstruction_data = self._reconstruction_data
@@ -393,21 +558,27 @@ def handle_export_wav_confirmed(self, filepath: Path) -> None:
logger.warning("No reconstruction data available for WAV export")
return
- audio_snapshot = reconstruction_data.get_partials(self._selected_generators)
+ audio_snapshot = reconstruction_data.partials_for(
+ self._selected_channels,
+ self._stem_selection,
+ )
sample_rate = reconstruction_data.reconstruction.config.sample_rate
self._session_manager.set_audio_path(filepath)
self._export_service.export_wav(filepath, sample_rate, audio_snapshot)
def handle_locate_original_audio(self) -> None:
- path = self._reconstruction_manager.audio_filepath
- if path is None:
+ if not self._reconstruction_manager.source_paths:
return
try:
self._reconstruction_manager.locate_original_audio()
except FileNotFoundError:
- logger.warning(f"Original audio file could not be found: '{logger.format_path(path)}'")
- self.call(self.on_locate_audio_not_found, path)
+ missing_path = first_missing(self._reconstruction_manager.source_paths)
+ if missing_path is None:
+ raise
+
+ logger.warning(f"Original audio file could not be found: '{logger.format_path(missing_path)}'")
+ self.call(self.on_locate_audio_not_found, missing_path)
def open_reconstruction_in_explorer(self) -> None:
"""Reveals the loaded reconstruction's own file in the OS file manager."""
@@ -417,13 +588,13 @@ def open_reconstruction_in_explorer(self) -> None:
open_path_in_explorer(filepath)
- def _get_instrument_name(self, generator_name: GeneratorName) -> str:
- """Names the loaded reconstruction's slice for one generator."""
+ def _get_instrument_name(self, channel_name: ChannelName) -> str:
+ """Names the loaded reconstruction's slice for one channel."""
reconstruction_data = self._reconstruction_data
if not reconstruction_data:
raise AssertionError("Expected reconstruction data to be present")
- return instrument_slice_name(reconstruction_data.name, generator_name)
+ return instrument_slice_name(reconstruction_data.name, channel_name)
def _emit_audio_data(self) -> None:
audio_data = self._compute_audio_data()
@@ -436,13 +607,16 @@ def _compute_audio_data(self) -> Optional[AudioData]:
sample_rate = reconstruction_data.reconstruction.config.sample_rate
if self._current_audio_source == AudioSourceType.ORIGINAL:
- original_audio = reconstruction_data.original_audio
- if original_audio is None:
+ if reconstruction_data.original_audio is None:
return None
- return AudioData.from_array(original_audio, sample_rate)
+ selected_original_audio = reconstruction_data.original_mix_for(self._stem_selection)
+ return AudioData.from_array(selected_original_audio, sample_rate)
- partial_approximation = reconstruction_data.get_partials(self._selected_generators)
+ partial_approximation = reconstruction_data.partials_for(
+ self._selected_channels,
+ self._stem_selection,
+ )
return AudioData.from_array(partial_approximation, sample_rate)
def _build_path_view_models(
@@ -470,36 +644,30 @@ def _build_file_path_view_model(
if filepath is None:
return ReconstructionPathViewModel(
state=ReconstructionPathState.NOT_APPLICABLE,
- path="",
+ paths=(),
)
return ReconstructionPathViewModel(
state=ReconstructionPathState.AVAILABLE,
- path=str(filepath),
+ paths=(str(filepath),),
)
@staticmethod
def _build_audio_path_view_model(
- audio_filepath: Optional[Path],
+ source_paths: Tuple[Path, ...],
original_audio: Optional[np.ndarray],
) -> ReconstructionPathViewModel:
"""Reports the original-audio location, treating a recorded path with unusable content
the same as a missing one, so the source toggle and waveform agree with what actually loaded."""
- if audio_filepath is None:
- return ReconstructionPathViewModel(
- state=ReconstructionPathState.NOT_APPLICABLE,
- path="",
- )
-
- if original_audio is None:
+ if source_paths and original_audio is None:
return ReconstructionPathViewModel(
state=ReconstructionPathState.NOT_FOUND,
- path="",
+ paths=(),
)
return ReconstructionPathViewModel(
- state=ReconstructionPathState.AVAILABLE,
- path=str(audio_filepath),
+ state=ReconstructionPathState.from_source_paths(source_paths),
+ paths=tuple(str(path) for path in source_paths),
)
@property
diff --git a/src/sampletones_application/logic/render/logic.py b/src/sampletones_application/logic/render/logic.py
index 0cc91e353..051f5e2ec 100644
--- a/src/sampletones_application/logic/render/logic.py
+++ b/src/sampletones_application/logic/render/logic.py
@@ -13,7 +13,7 @@
from sampletones_application.logic.shared.project_source import ProjectSnapshot
from sampletones_application.services.render.result import RenderResult, RenderStage
from sampletones_application.services.result import (
- ServiceCancelled,
+ ServiceCanceled,
ServiceError,
ServiceProgress,
ServiceStarted,
@@ -69,7 +69,7 @@ def __init__(
self._service = render_service
self._is_operation_active = is_operation_active
self._msg_cancelling = language_manager["settings.render.message.status_cancelling"]
- self._msg_cancelled = language_manager["settings.render.message.status_cancelled"]
+ self._msg_canceled = language_manager["settings.render.message.status_canceled"]
self._msg_completed = language_manager["settings.render.message.status_completed"]
self._msg_failed = language_manager["settings.render.message.status_failed"]
self._eta_template = language_manager["global.dialog.template.time_estimation"]
@@ -91,7 +91,7 @@ def __init__(
self.on_choose_destination: Optional[Callable[[Path, AudioFormat], None]] = None
self.on_success: Optional[PathCallback] = None
self.on_error: Optional[Callable[[Exception], None]] = None
- self.on_cancelled: Optional[VoidCallback] = None
+ self.on_canceled: Optional[VoidCallback] = None
@property
def is_active(self) -> bool:
@@ -206,7 +206,7 @@ def _on_service_result(self, result: RenderResult) -> None:
self._on_render_complete(destination)
case ServiceError(exception=exception):
self._on_render_error(exception)
- case ServiceCancelled():
+ case ServiceCanceled():
self._on_cancellation_complete()
def _handle_progress(self, progress: ServiceProgress[RenderStage]) -> None:
@@ -217,7 +217,7 @@ def _handle_progress(self, progress: ServiceProgress[RenderStage]) -> None:
self._phase = RenderPhase.RENDERING
stage = progress.current_item
status_text = self._status_text if stage is None else self._stage_status(stage, progress.eta_seconds)
- self._report(status_text, progress.completed / max(progress.total, 1))
+ self._report(status_text, progress.fraction)
def _stage_status(self, stage: RenderStage, eta_seconds: Optional[float]) -> str:
"""What the pass is doing, and how long it has left where an estimate stands."""
@@ -239,9 +239,9 @@ def _on_render_error(self, exception: Exception) -> None:
self.call(self.on_error, exception)
def _on_cancellation_complete(self) -> None:
- self._phase = RenderPhase.CANCELLED
- self._report(self._msg_cancelled, 0.0)
- self.call(self.on_cancelled)
+ self._phase = RenderPhase.CANCELED
+ self._report(self._msg_canceled, 0.0)
+ self.call(self.on_canceled)
def _report(self, status_text: str, progress: float) -> None:
self._status_text = status_text
diff --git a/src/sampletones_application/logic/sequencer/browser.py b/src/sampletones_application/logic/sequencer/browser.py
index 43fc35655..27c105706 100644
--- a/src/sampletones_application/logic/sequencer/browser.py
+++ b/src/sampletones_application/logic/sequencer/browser.py
@@ -3,7 +3,7 @@
from sampletones_application.config.managers.config import ConfigManager
from sampletones_application.logic.project.controller import ProjectController
from sampletones_application.logic.reconstruction.browser.manager import BrowserManager
-from sampletones_core.project.instruments.sample import Sample
+from sampletones_core.project.voices.sample import Sample
from sampletones_core.reconstructions import Reconstruction
from sampletones_core.structures.tree import Tree
from sampletones_shared.utils.callbacks import CallbackMixin
@@ -51,7 +51,7 @@ def add_reconstruction(
def replace_reconstruction(
self,
- sample_id: str,
+ voice_id: str,
reconstruction: Reconstruction,
) -> None:
"""Substitutes an existing sample's reconstruction with an already-loaded one.
@@ -59,4 +59,4 @@ def replace_reconstruction(
The sample keeps its identity, so the patterns referencing it sound the new
reconstruction while their rows stay as they were.
"""
- self._controller.replace_sample_reconstruction(sample_id, reconstruction)
+ self._controller.replace_sample_reconstruction(voice_id, reconstruction)
diff --git a/src/sampletones_application/logic/sequencer/channels.py b/src/sampletones_application/logic/sequencer/channels.py
index aff9faef8..b7371316d 100644
--- a/src/sampletones_application/logic/sequencer/channels.py
+++ b/src/sampletones_application/logic/sequencer/channels.py
@@ -3,11 +3,11 @@
from sampletones_application.view_model.sequencer.channels import (
SequencerChannelsViewModel,
)
-from sampletones_core.constants.enums import GeneratorName
+from sampletones_core.constants.enums import ChannelName
from sampletones_shared.utils.callbacks import CallbackMixin
-ALL_CHANNELS: Final[FrozenSet[GeneratorName]] = frozenset(GeneratorName.items())
-_NO_CHANNELS: Final[FrozenSet[GeneratorName]] = frozenset()
+ALL_CHANNELS: Final[FrozenSet[ChannelName]] = frozenset(ChannelName.items())
+_NO_CHANNELS: Final[FrozenSet[ChannelName]] = frozenset()
class SequencerChannelsLogic(CallbackMixin):
@@ -27,13 +27,13 @@ class SequencerChannelsLogic(CallbackMixin):
"""
def __init__(self) -> None:
- self._muted: FrozenSet[GeneratorName] = _NO_CHANNELS
- self._muted_before_solo: Optional[FrozenSet[GeneratorName]] = None
+ self._muted: FrozenSet[ChannelName] = _NO_CHANNELS
+ self._muted_before_solo: Optional[FrozenSet[ChannelName]] = None
self.on_channels_changed: Optional[Callable[[SequencerChannelsViewModel], None]] = None
@property
- def active_channels(self) -> FrozenSet[GeneratorName]:
+ def active_channels(self) -> FrozenSet[ChannelName]:
"""The channels that sound, the mask the synthesiser mixes."""
return ALL_CHANNELS - self._muted
@@ -43,19 +43,19 @@ def build_channels(self) -> SequencerChannelsViewModel:
def push_channels(self) -> None:
self.call(self.on_channels_changed, self.build_channels())
- def toggle(self, generator: GeneratorName) -> None:
+ def toggle(self, channel: ChannelName) -> None:
"""Flips one channel between audible and silent."""
self._muted_before_solo = None
- self._apply(self._muted ^ {generator})
+ self._apply(self._muted ^ {channel})
- def solo(self, generator: GeneratorName) -> None:
- """Silences every other channel, restoring the previous mix once ``generator`` plays alone.
+ def solo(self, channel: ChannelName) -> None:
+ """Silences every other channel, restoring the previous mix once ``channel`` plays alone.
The mute set in force when the solo starts is remembered, so a second solo of the same
channel returns to it. Editing the mute set by hand adopts that set as the state a later
solo returns to.
"""
- others = ALL_CHANNELS - {generator}
+ others = ALL_CHANNELS - {channel}
if self._muted == others:
restored = self._muted_before_solo if self._muted_before_solo is not None else _NO_CHANNELS
self._muted_before_solo = None
@@ -92,6 +92,6 @@ def reset(self) -> None:
"""Starts a fresh listening session, the state a newly opened document is heard in."""
self.unmute_all()
- def _apply(self, muted: FrozenSet[GeneratorName]) -> None:
+ def _apply(self, muted: FrozenSet[ChannelName]) -> None:
self._muted = muted
self.push_channels()
diff --git a/src/sampletones_application/logic/sequencer/clipboard/samples.py b/src/sampletones_application/logic/sequencer/clipboard/samples.py
index 2e4099521..5459c5c6e 100644
--- a/src/sampletones_application/logic/sequencer/clipboard/samples.py
+++ b/src/sampletones_application/logic/sequencer/clipboard/samples.py
@@ -6,7 +6,7 @@
class SampleDirectory(Protocol):
"""The samples a note can name, read the way a grid prints them: by list position."""
- def position_of(self, sample_id: str) -> Optional[int]: ...
+ def position_of(self, voice_id: str) -> Optional[int]: ...
def sample_at(self, position: int) -> Optional[str]: ...
@@ -21,17 +21,17 @@ class ProjectSampleDirectory:
def __init__(self, project_controller: ProjectController) -> None:
self._controller = project_controller
- def position_of(self, sample_id: str) -> Optional[int]:
+ def position_of(self, voice_id: str) -> Optional[int]:
"""Where a sample stands in the list, present while the project holds it."""
- samples = self._controller.project.samples
- if samples.get(sample_id) is None:
+ samples = self._controller.project.voices
+ if samples.get(voice_id) is None:
return None
- return samples.get_index(sample_id)
+ return samples.get_index(voice_id)
def sample_at(self, position: int) -> Optional[str]:
"""The sample a position names, present while the list reaches that far."""
- samples = self._controller.project.samples
+ samples = self._controller.project.voices
if 0 <= position < len(samples):
return samples[position].id
diff --git a/src/sampletones_application/logic/sequencer/clipboard/tracker.py b/src/sampletones_application/logic/sequencer/clipboard/tracker.py
index 53b4f2b30..aa95dec50 100644
--- a/src/sampletones_application/logic/sequencer/clipboard/tracker.py
+++ b/src/sampletones_application/logic/sequencer/clipboard/tracker.py
@@ -18,14 +18,14 @@
MIN_TRANSPOSE,
SILENT_VOLUME,
)
-from sampletones_core.project.instruments.note_off import NoteOff
+from sampletones_core.project.voices.note_off import NoteOff
from sampletones_core.utils.display import (
NOTE_OFF,
display_id,
display_transpose,
display_volume,
)
-from sampletones_shared.constants.symbols import PLUS, SIGNS
+from sampletones_shared.constants.symbols import PIPE, PLUS, SIGNS
from .fields import (
FieldReading,
@@ -39,7 +39,7 @@
TRACKER_GRID: Final[str] = "tracker"
SLOT_KEY: Final[str] = "slots"
-COLUMN_SEPARATOR: Final[str] = "|"
+COLUMN_SEPARATOR: Final[str] = PIPE
NOTE_WIDTH: Final[int] = len(display_id(None))
TRANSPOSE_WIDTH: Final[int] = len(display_transpose(None))
VOLUME_WIDTH: Final[int] = len(display_volume(None))
@@ -105,10 +105,10 @@ def _state_row(
row_offset: int,
) -> str:
"""One row of the block, its fields in slot order and its columns held apart by a bar."""
- base = column_slot_base(slot_from_flat(region.first_slot).generator)
+ base = column_slot_base(slot_from_flat(region.first_slot).channel)
fields: List[str] = []
for position, slot in enumerate(region.slots):
- if position > 0 and slot.generator != region.slots[position - 1].generator:
+ if position > 0 and slot.channel != region.slots[position - 1].channel:
fields.append(COLUMN_SEPARATOR)
key = (row_offset, region.first_slot + position - base)
@@ -123,7 +123,7 @@ def _state_slot(
key: BlockKey,
) -> str:
match subcolumn:
- case SubColumn.INSTRUMENT:
+ case SubColumn.VOICE:
return self._state_note(block.notes, key)
case SubColumn.TRANSPOSE:
return self._state_number(
@@ -156,8 +156,8 @@ def _state_note(
match notes[key]:
case NoteOff():
return NOTE_OFF
- case str() as sample_id:
- position = self._samples.position_of(sample_id)
+ case str() as voice_id:
+ position = self._samples.position_of(voice_id)
return state_mixed(NOTE_WIDTH) if position is None else display_id(position)
case _:
return display_id(None)
@@ -180,7 +180,7 @@ def _read_rows(
shape: BlockShape,
) -> Optional[TrackerBlock]:
"""The block a body states, each kind of subcolumn gathered into a map of its own."""
- base = column_slot_base(slot_from_flat(shape.first).generator)
+ base = column_slot_base(slot_from_flat(shape.first).channel)
notes: Dict[BlockKey, Optional[BlockNote]] = {}
transposes: Dict[BlockKey, Optional[int]] = {}
volumes: Dict[BlockKey, Optional[int]] = {}
@@ -193,7 +193,7 @@ def _read_rows(
slot = slot_from_flat(shape.first + position)
key = (row_offset, shape.first + position - base)
match slot.subcolumn:
- case SubColumn.INSTRUMENT:
+ case SubColumn.VOICE:
read = store_reading(
notes,
key,
@@ -238,8 +238,8 @@ def _read_note(self, field: str) -> Optional[FieldReading[BlockNote]]:
if position is None:
return None
- sample_id = self._samples.sample_at(position)
- return FieldReading.mixed() if sample_id is None else FieldReading.of(sample_id)
+ voice_id = self._samples.sample_at(position)
+ return FieldReading.mixed() if voice_id is None else FieldReading.of(voice_id)
@staticmethod
def _read_transpose(field: str) -> Optional[FieldReading[int]]:
diff --git a/src/sampletones_application/logic/sequencer/history_detail.py b/src/sampletones_application/logic/sequencer/history_detail.py
index 32f1672b8..faf115472 100644
--- a/src/sampletones_application/logic/sequencer/history_detail.py
+++ b/src/sampletones_application/logic/sequencer/history_detail.py
@@ -1,7 +1,7 @@
from typing import Dict, Final, List, Optional, Set
-from sampletones_application.logic.sequencer.samples import SequencerSamplesLogic
from sampletones_application.logic.sequencer.tracker import SequencerTrackerLogic
+from sampletones_application.logic.sequencer.voices import SequencerVoicesLogic
from sampletones_application.view_model.sequencer.region import (
OrderCell,
OrderRegion,
@@ -9,19 +9,19 @@
TrackerRegion,
)
from sampletones_application.view_model.sequencer.subcolumn import SubColumn
+from sampletones_application.view_model.sequencer.voices import VoiceKind
from sampletones_application.view_model.shared.history import (
HistoryDetail,
HistoryDetailRole,
HistoryDetailSegment,
- HistoryDetailWord,
- HistoryDetailWordSegment,
)
from sampletones_core.constants.enums import (
+ ChannelName,
FeatureKey,
- GeneratorName,
- abbreviate_generator_names,
+ abbreviate_channel_names,
)
from sampletones_core.utils.display import display_id, display_transpose, display_volume
+from sampletones_core.utils.pitch_kind import channel_pitch_kind
Segments = HistoryDetail
@@ -30,15 +30,19 @@
_SUBCOLUMN_LETTERS: Final[Dict[SubColumn, str]] = {
- SubColumn.INSTRUMENT: "i",
+ SubColumn.VOICE: "n",
SubColumn.TRANSPOSE: "t",
SubColumn.VOLUME: "v",
}
_SUBCOLUMN_ROLES: Final[Dict[SubColumn, HistoryDetailRole]] = {
- SubColumn.INSTRUMENT: HistoryDetailRole.INSTRUMENT,
+ SubColumn.VOICE: HistoryDetailRole.VOICE,
SubColumn.TRANSPOSE: HistoryDetailRole.TRANSPOSE,
SubColumn.VOLUME: HistoryDetailRole.VOLUME,
}
+_KIND_ROLES: Final[Dict[VoiceKind, HistoryDetailRole]] = {
+ VoiceKind.SAMPLE: HistoryDetailRole.SAMPLE,
+ VoiceKind.INSTRUMENT: HistoryDetailRole.INSTRUMENT,
+}
_FEATURE_LETTERS: Final[Dict[FeatureKey, str]] = {
FeatureKey.INITIAL_PITCH: "i",
FeatureKey.VOLUME: "v",
@@ -62,41 +66,53 @@ def _span(first: int, last: int) -> str:
return f"{display_id(first)}{_RANGE}{display_id(last)}"
+def _kind_role(kind: Optional[VoiceKind]) -> HistoryDetailRole:
+ """The role a voice reads under, so its line wears the color of the kind it is about.
+
+ A voice the pool has stopped holding keeps the plain voice role, the same one the tracker's
+ voice slot wears while it names nothing. A tracker cell naming a voice since removed is read
+ that way, the same as :func:`display_voice` reads its position.
+ """
+ if kind is None:
+ return HistoryDetailRole.VOICE
+
+ return _KIND_ROLES[kind]
+
+
class SequencerHistoryDetail:
- """Builds the coloured detail line for each undoable sequencer gesture.
+ """Builds the colored detail line for each undoable sequencer gesture.
Every method mirrors the signature of the coordinator hook it describes, so it
can be handed straight to ``_undoable`` as the ``detail`` callable. Each returns
an ordered tuple of :class:`HistoryDetailSegment`, tagging each token with a
semantic role that the panel later paints. Positions, rows and pattern indices
read as two-digit hex; channels use the ``P``/``p``/``T``/``N`` abbreviations,
- concatenated when a sample-column gesture spans several channels.
- Language-managed words — the loop on/off states — are emitted as
- :class:`HistoryDetailWordSegment` keys and translated when the history view is
- built, keeping committed entries language-independent.
+ concatenated when a sample-column gesture spans several channels. A gesture on the
+ voice pool names its voice in the color of the kind that voice is, so a recording
+ and a hand-written one read apart down the list of entries.
"""
def __init__(
self,
tracker_logic: SequencerTrackerLogic,
- samples_logic: SequencerSamplesLogic,
+ voices_logic: SequencerVoicesLogic,
) -> None:
self._tracker_logic = tracker_logic
- self._samples_logic = samples_logic
+ self._voices_logic = voices_logic
def edit_row(
self,
row_index: int,
- generator: Optional[GeneratorName],
- sample_id: Optional[str],
+ channel: Optional[ChannelName],
+ voice_id: Optional[str],
transpose: Optional[int],
volume: Optional[int],
) -> Segments:
- affected = self._edit_row_generators(generator, sample_id, row_index)
- segments = list(self._location(row_index, generator, affected))
- if sample_id is not None:
+ affected = self._edit_row_channels(channel, voice_id, row_index)
+ segments = list(self._location(row_index, channel, affected))
+ if voice_id is not None:
segments.append(self._arrow())
- segments.append(self._sample(sample_id))
+ segments.append(self._voice(voice_id))
if transpose is not None:
segments.append(self._subcolumn(SubColumn.TRANSPOSE))
@@ -115,32 +131,47 @@ def edit_row(
return tuple(segments)
+ def note_typed(
+ self,
+ row_index: int,
+ channel: ChannelName,
+ pitch: int,
+ ) -> Segments:
+ """Names a typed note by the cell it landed in and the note the key stood for."""
+ segments = list(self._location(row_index, channel, [channel]))
+ segments.append(self._subcolumn(SubColumn.TRANSPOSE))
+ segments.append(
+ self._segment(
+ channel_pitch_kind(channel).to_name(pitch),
+ HistoryDetailRole.TRANSPOSE,
+ ),
+ )
+ return tuple(segments)
+
def note_off(
self,
row_index: int,
- generator: Optional[GeneratorName],
+ channel: Optional[ChannelName],
) -> Segments:
- return self._location(row_index, generator, GeneratorName.items())
+ return self._location(row_index, channel, ChannelName.items())
def clear_row(
self,
row_index: int,
- generator: Optional[GeneratorName],
+ channel: Optional[ChannelName],
) -> Segments:
- return self._location(row_index, generator, GeneratorName.items())
+ return self._location(row_index, channel, ChannelName.items())
def clear_subcolumn(
self,
row_index: int,
- generator: Optional[GeneratorName],
+ channel: Optional[ChannelName],
subcolumn: SubColumn,
) -> Segments:
affected = (
- GeneratorName.items()
- if subcolumn is SubColumn.INSTRUMENT
- else self._tracker_logic.relevant_generators(row_index)
+ ChannelName.items() if subcolumn is SubColumn.VOICE else self._tracker_logic.relevant_channels(row_index)
)
- segments = list(self._location(row_index, generator, affected))
+ segments = list(self._location(row_index, channel, affected))
segments.append(self._subcolumn(subcolumn))
return tuple(segments)
@@ -164,20 +195,20 @@ def tracker_block(self, region: TrackerRegion) -> Segments:
def tracker_paste(self, cell: TrackerCell) -> Segments:
"""Reads as the cell a block was written from, the one place a paste chooses."""
- return self._location(cell.row, cell.generator, GeneratorName.items())
+ return self._location(cell.row, cell.channel, ChannelName.items())
def order_block(self, region: OrderRegion) -> Segments:
"""Reads as the positions a block covers and the channels its rows reach."""
return (
self._frame_range(region.first_position, region.last_position),
- self._channel(self._covered_channels(set(region.generators))),
+ self._channel(self._covered_channels(set(region.channels))),
)
def order_paste(self, cell: OrderCell) -> Segments:
"""Reads as the cell a block was written from, the one place a paste chooses."""
return (
self._frame(cell.position),
- self._channel(self._covered_channels({cell.generator})),
+ self._channel(self._covered_channels({cell.channel})),
)
def add_frame(self, position: int) -> Segments:
@@ -202,13 +233,13 @@ def move_frame(self, from_position: int, to_position: int) -> Segments:
def set_order_entry(
self,
- generator: GeneratorName,
+ channel: ChannelName,
position: int,
pattern_index: Optional[int],
) -> Segments:
return (
self._frame(position),
- self._channel([generator]),
+ self._channel([channel]),
self._arrow(),
self._value(display_id(pattern_index)),
)
@@ -220,90 +251,118 @@ def set_master_entry(
) -> Segments:
return (
self._frame(position),
- self._channel(GeneratorName.items()),
+ self._channel(ChannelName.items()),
self._arrow(),
self._value(display_id(pattern_index)),
)
def add_sample(self, name: str) -> Segments:
- return (self._name(name),)
+ return (self._name(name, VoiceKind.SAMPLE),)
+
+ def add_instrument(self, name: str) -> Segments:
+ return (self._name(name, VoiceKind.INSTRUMENT),)
- def remove_sample(self, sample_id: str) -> Segments:
+ def remove_voice(self, voice_id: str) -> Segments:
return (
- self._sample(sample_id, colon=True),
- self._name(self._samples_logic.sample_name(sample_id)),
+ self._voice(voice_id, colon=True),
+ self._voice_name(voice_id),
)
- def replace_sample(self, sample_id: str, name: str) -> Segments:
+ def replace_sample(self, voice_id: str, name: str) -> Segments:
"""Describes a reconstruction substitution as the sample's position and the two names.
``name`` is the incoming reconstruction's, read against the sample's current one, so the
caller builds this detail while the sample still holds the reconstruction being replaced.
"""
return (
- self._sample(sample_id, colon=True),
- self._name(self._samples_logic.sample_name(sample_id)),
+ self._voice(voice_id, colon=True),
+ self._voice_name(voice_id),
self._arrow(),
- self._name(name),
+ self._name(name, VoiceKind.SAMPLE),
)
- def rename_sample(self, old_name: str, new_name: str) -> Segments:
- return (self._name(old_name), self._arrow(), self._name(new_name))
+ def rename_voice(self, voice_id: str, name: str) -> Segments:
+ """Describes a rename as the name the voice carries and the one it takes.
- def move_sample(self, sample_id: str, to_index: int) -> Segments:
+ Both read in the voice's own kind, which the caller builds this detail under while the
+ pool still holds the name being left behind.
+ """
return (
- self._sample(sample_id),
+ self._voice_name(voice_id),
self._arrow(),
- self._value(display_id(to_index)),
+ self._name(name, self._voices_logic.voice_kind(voice_id)),
)
- def duplicate_sample(self, sample_id: str) -> Segments:
+ def move_voice(self, voice_id: str, to_index: int) -> Segments:
return (
- self._sample(sample_id, colon=True),
- self._name(self._samples_logic.sample_name(sample_id)),
+ self._voice(voice_id),
+ self._arrow(),
+ self._value(display_id(to_index)),
)
- def set_sample_loop(self, sample_id: str, loop: bool) -> Segments:
- word = HistoryDetailWord.LOOP_ON if loop else HistoryDetailWord.LOOP_OFF
+ def duplicate_voice(self, voice_id: str) -> Segments:
return (
- self._sample(sample_id, colon=True),
- HistoryDetailWordSegment(word=word, role=HistoryDetailRole.VALUE),
+ self._voice(voice_id, colon=True),
+ self._voice_name(voice_id),
)
def edit_reconstruction(
self,
- sample_id: str,
- generator_name: GeneratorName,
+ voice_id: str,
+ channel_name: ChannelName,
feature_key: FeatureKey,
) -> Segments:
"""Describes a regenerated sample: its position, channel, and edited feature.
The channel and the feature both render abbreviated — the ``P``/``p``/``T``/``N``
- channel letter and the feature's one-letter code in the same colour the details
+ channel letter and the feature's one-letter code in the same color the details
tab plots it with — mirroring the tracker rows.
"""
return (
- self._sample(sample_id, colon=True),
- self._channel([generator_name]),
+ self._voice(voice_id, colon=True),
+ self._channel([channel_name]),
+ self._segment(_FEATURE_LETTERS[feature_key], _FEATURE_ROLES[feature_key]),
+ )
+
+ def edit_instrument(
+ self,
+ voice_id: str,
+ feature_key: FeatureKey,
+ ) -> Segments:
+ """Describes a hand-written voice's edited dimension: its position and the dimension.
+
+ An instrument is one set of envelopes every channel reads what it can of, so the line
+ names the dimension alone, as the feature's one-letter code in the color the details tab
+ plots it with.
+ """
+ return (
+ self._voice(voice_id, colon=True),
self._segment(_FEATURE_LETTERS[feature_key], _FEATURE_ROLES[feature_key]),
)
+ def remove_stem(self, voice_id: str, stem_name: str) -> Segments:
+ """Describes a recording taken out of a sample's reconstruction: its position and name."""
+ return (
+ self._voice(voice_id, colon=True),
+ self._name(stem_name, self._voices_logic.voice_kind(voice_id)),
+ )
+
def value(self, number: int) -> Segments:
return (self._value(str(number)),)
- def _edit_row_generators(
+ def _edit_row_channels(
self,
- generator: Optional[GeneratorName],
- sample_id: Optional[str],
+ channel: Optional[ChannelName],
+ voice_id: Optional[str],
row_index: int,
- ) -> List[GeneratorName]:
- if generator is not None:
- return [generator]
+ ) -> List[ChannelName]:
+ if channel is not None:
+ return [channel]
- if sample_id is not None:
- return self._tracker_logic.used_generators(sample_id)
+ if voice_id is not None:
+ return self._tracker_logic.used_generators(voice_id)
- return self._tracker_logic.relevant_generators(row_index)
+ return self._tracker_logic.relevant_channels(row_index)
def _tracker_region(self, region: TrackerRegion) -> Segments:
"""Reads a rectangle of the tracker as its frame, the channels it spans and the rows it covers.
@@ -320,10 +379,10 @@ def _tracker_region(self, region: TrackerRegion) -> Segments:
def _location(
self,
row_index: int,
- generator: Optional[GeneratorName],
- affected: List[GeneratorName],
+ channel: Optional[ChannelName],
+ affected: List[ChannelName],
) -> Segments:
- channels = [generator] if generator is not None else affected
+ channels = [channel] if channel is not None else affected
return (
self._frame(self._tracker_logic.frame_index),
self._channel(channels),
@@ -352,7 +411,11 @@ def _row_range(self, first_row: int, last_row: int) -> HistoryDetailSegment:
role=HistoryDetailRole.ROW,
)
- def _frame_range(self, first_position: int, last_position: int) -> HistoryDetailSegment:
+ def _frame_range(
+ self,
+ first_position: int,
+ last_position: int,
+ ) -> HistoryDetailSegment:
"""Reads a span of positions as one frame token, a single position standing as its own."""
if first_position == last_position:
return self._frame(first_position)
@@ -363,20 +426,22 @@ def _frame_range(self, first_position: int, last_position: int) -> HistoryDetail
)
@staticmethod
- def _covered_channels(covered: Set[Optional[GeneratorName]]) -> List[GeneratorName]:
- """The channels a run of columns names, an aggregate one standing for all it summarises.
+ def _covered_channels(
+ covered: Set[Optional[ChannelName]],
+ ) -> List[ChannelName]:
+ """The channels a run of columns names, an aggregate one standing for all it summarizes.
Both grids carry a column that answers for every channel — the tracker's sample column and
the order's master row — so a gesture reaching one of them reads as the whole set.
"""
if None in covered:
- return GeneratorName.items()
+ return ChannelName.items()
- return [generator for generator in GeneratorName.items() if generator in covered]
+ return [channel for channel in ChannelName.items() if channel in covered]
- def _channel(self, generators: List[GeneratorName]) -> HistoryDetailSegment:
+ def _channel(self, channels: List[ChannelName]) -> HistoryDetailSegment:
return HistoryDetailSegment(
- text=abbreviate_generator_names(generators),
+ text=abbreviate_channel_names(channels),
role=HistoryDetailRole.CHANNEL,
)
@@ -396,21 +461,32 @@ def _subcolumn(self, subcolumn: SubColumn) -> HistoryDetailSegment:
_SUBCOLUMN_ROLES[subcolumn],
)
- def _name(self, text: str) -> HistoryDetailSegment:
- return HistoryDetailSegment(
- text=text,
- role=HistoryDetailRole.NAME,
+ def _name(
+ self,
+ text: str,
+ kind: Optional[VoiceKind],
+ ) -> HistoryDetailSegment:
+ return HistoryDetailSegment(text=text, role=_kind_role(kind))
+
+ def _voice_name(self, voice_id: str) -> HistoryDetailSegment:
+ """The name a voice in the pool carries, read in the color of the kind it is."""
+ return self._name(
+ self._voices_logic.voice_name(voice_id),
+ self._voices_logic.voice_kind(voice_id),
)
- def _sample(
+ def _voice(
self,
- sample_id: str,
+ voice_id: str,
*,
colon: bool = False,
) -> HistoryDetailSegment:
- position = self._samples_logic.sample_position(sample_id)
+ position = self._voices_logic.voice_position(voice_id)
text = f"{position}:" if colon else position
- return HistoryDetailSegment(text=text, role=HistoryDetailRole.SAMPLE)
+ return HistoryDetailSegment(
+ text=text,
+ role=_kind_role(self._voices_logic.voice_kind(voice_id)),
+ )
def _arrow(self) -> HistoryDetailSegment:
return HistoryDetailSegment(
diff --git a/src/sampletones_application/logic/sequencer/order/order.py b/src/sampletones_application/logic/sequencer/order/order.py
index 15d27ac5e..fd07511b3 100644
--- a/src/sampletones_application/logic/sequencer/order/order.py
+++ b/src/sampletones_application/logic/sequencer/order/order.py
@@ -6,7 +6,7 @@
SequencerOrderTrackerViewModel,
SequencerOrderViewModel,
)
-from sampletones_core.constants.enums import GeneratorName
+from sampletones_core.constants.enums import ChannelName
from sampletones_core.project.song import Song
from sampletones_shared.utils.callbacks import CallbackMixin
@@ -26,7 +26,7 @@ def __init__(self, project_controller: ProjectController) -> None:
def build_order(self) -> SequencerOrderTrackerViewModel:
song = self._controller.project.song
- channels = {generator: self._build_channel_view(generator, song) for generator in GeneratorName.items()}
+ channels = {channel: self._build_channel_view(channel, song) for channel in ChannelName.items()}
return SequencerOrderTrackerViewModel(
position_count=song.order_length(),
channels=channels,
@@ -40,19 +40,19 @@ def refresh(self) -> None:
def set_order_entry(
self,
- generator: GeneratorName,
+ channel: ChannelName,
position: int,
pattern_index: Optional[int],
) -> None:
- self._controller.set_order_entry(generator, position, pattern_index)
+ self._controller.set_order_entry(channel, position, pattern_index)
def set_master_entry(self, position: int, pattern_index: Optional[int]) -> None:
- for generator in GeneratorName.items():
- self._controller.set_order_entry(generator, position, pattern_index)
+ for channel in ChannelName.items():
+ self._controller.set_order_entry(channel, position, pattern_index)
def write_entry(
self,
- generator: Optional[GeneratorName],
+ channel: Optional[ChannelName],
position: int,
pattern_index: Optional[int],
) -> None:
@@ -61,18 +61,18 @@ def write_entry(
This is the rule the table's two kinds of row follow, kept in one place so a gesture
reaching across them writes what the reader typing into each by hand would.
"""
- if generator is None:
+ if channel is None:
self.set_master_entry(position, pattern_index)
else:
- self.set_order_entry(generator, position, pattern_index)
+ self.set_order_entry(channel, position, pattern_index)
- def entry(self, generator: GeneratorName, position: int) -> Optional[int]:
+ def entry(self, channel: ChannelName, position: int) -> Optional[int]:
"""The pattern index a channel plays at a position, empty past the order's last frame."""
order = self._controller.song.order
if position >= len(order):
return None
- return order[position].get(generator)
+ return order[position].get(channel)
def position_count(self) -> int:
return self._controller.order_length
@@ -105,17 +105,17 @@ def move_frame(self, from_position: int, to_position: int) -> None:
def _build_channel_view(
self,
- generator: GeneratorName,
+ channel: ChannelName,
song: Song,
) -> SequencerOrderViewModel:
entries = tuple(
OrderEntryViewModel(
position=position,
- pattern_index=frame.get(generator),
+ pattern_index=frame.get(channel),
)
for position, frame in enumerate(song.order)
)
return SequencerOrderViewModel(
- generator=generator,
+ channel=channel,
entries=entries,
)
diff --git a/src/sampletones_application/logic/sequencer/order/reader.py b/src/sampletones_application/logic/sequencer/order/reader.py
index c14819701..5da26b58e 100644
--- a/src/sampletones_application/logic/sequencer/order/reader.py
+++ b/src/sampletones_application/logic/sequencer/order/reader.py
@@ -1,7 +1,7 @@
from typing import Dict, Optional
from sampletones_application.view_model.sequencer.region import OrderRegion
-from sampletones_core.constants.enums import GeneratorName
+from sampletones_core.constants.enums import ChannelName
from sampletones_shared.utils.agreement import Agreement
from .block import BlockKey, OrderBlock
@@ -26,9 +26,9 @@ def read(self, region: OrderRegion) -> OrderBlock:
value the paste passes by.
"""
entries: Dict[BlockKey, Optional[int]] = {}
- for row_offset, generator in enumerate(region.generators):
+ for row_offset, channel in enumerate(region.channels):
for position_offset, position in enumerate(region.positions):
- agreement = self._agree(generator, position)
+ agreement = self._agree(channel, position)
if agreement.is_unanimous:
entries[(row_offset, position_offset)] = agreement.value
@@ -36,16 +36,16 @@ def read(self, region: OrderRegion) -> OrderBlock:
def _agree(
self,
- generator: Optional[GeneratorName],
+ channel: Optional[ChannelName],
position: int,
) -> Agreement[Optional[int]]:
"""What a row holds at a position: a channel's own index, or the one its channels share.
A channel row answers for itself, so it is a group of one and always agrees. The master row
- answers for every channel, which is the group its display summarises too, so a block states
+ answers for every channel, which is the group its display summarizes too, so a block states
about a cell exactly what the table it came from shows there.
"""
- if generator is not None:
- return Agreement.collapse([self._order.entry(generator, position)])
+ if channel is not None:
+ return Agreement.collapse([self._order.entry(channel, position)])
- return Agreement.collapse(self._order.entry(channel, position) for channel in GeneratorName.items())
+ return Agreement.collapse(self._order.entry(channel, position) for channel in ChannelName.items())
diff --git a/src/sampletones_application/logic/sequencer/order/writer.py b/src/sampletones_application/logic/sequencer/order/writer.py
index 5e4a90de3..e108d3851 100644
--- a/src/sampletones_application/logic/sequencer/order/writer.py
+++ b/src/sampletones_application/logic/sequencer/order/writer.py
@@ -37,9 +37,9 @@ def clear(self, region: OrderRegion) -> None:
The order keeps its length, so emptying the frames at its end leaves them standing as
silent ones rather than taking positions away from the arrangement.
"""
- for generator in region.generators:
+ for channel in region.channels:
for position in region.positions:
- self._order.write_entry(generator, position, None)
+ self._order.write_entry(channel, position, None)
def _resolve(self, block: OrderBlock, cell: OrderCell) -> List[OrderWrite]:
"""Where each of a block's entries lands, in the reading order they are written in.
@@ -49,7 +49,7 @@ def _resolve(self, block: OrderBlock, cell: OrderCell) -> List[OrderWrite]:
is left out, which clips a block at the bottom edge rather than wrapping it round to the
master row.
"""
- base_row = CHANNEL_AXIS.index(cell.generator)
+ base_row = CHANNEL_AXIS.index(cell.channel)
return [
(base_row + row_offset, cell.position + position_offset, pattern_index)
for (row_offset, position_offset), pattern_index in sorted(block.entries.items())
diff --git a/src/sampletones_application/logic/sequencer/playback/protocol.py b/src/sampletones_application/logic/sequencer/playback/protocol.py
index c605c8649..37e50b531 100644
--- a/src/sampletones_application/logic/sequencer/playback/protocol.py
+++ b/src/sampletones_application/logic/sequencer/playback/protocol.py
@@ -15,7 +15,7 @@ class ChannelGeneratorProtocol(Protocol):
``PulseGenerator``) are accepted while keeping their precise generic types.
The instruction parameter is typed ``Any`` because the generator-to-instruction
- pairing is a runtime invariant maintained by ``GENERATOR_CLASSES`` dispatch, which
+ pairing is a runtime invariant maintained by ``CHANNEL_CLASSES`` dispatch, which
lies outside the static type system.
``frame_length`` is settable so the synthesiser can give each tick the span its clock
diff --git a/src/sampletones_application/logic/sequencer/playback/song_player.py b/src/sampletones_application/logic/sequencer/playback/song_player.py
index a30e3b573..6681c4525 100644
--- a/src/sampletones_application/logic/sequencer/playback/song_player.py
+++ b/src/sampletones_application/logic/sequencer/playback/song_player.py
@@ -114,7 +114,7 @@ def pause_or_resume(self) -> None:
def seek(self, order_position: int) -> None:
"""Moves the live playhead to another order, preserving sounding voices.
- Drives the follow-playback behaviour: selecting a different order while the song plays
+ Drives the follow-playback behavior: selecting a different order while the song plays
relocates the playhead in place. The service stays idle when nothing is playing.
The worker emits each row's position only after its blocking write, so one in-flight update
diff --git a/src/sampletones_application/logic/sequencer/playback/synthesizer/__init__.py b/src/sampletones_application/logic/sequencer/playback/synthesizer/__init__.py
index a60f7ba81..e88fdc0a7 100644
--- a/src/sampletones_application/logic/sequencer/playback/synthesizer/__init__.py
+++ b/src/sampletones_application/logic/sequencer/playback/synthesizer/__init__.py
@@ -1,12 +1,9 @@
from .bank import ChannelBank
from .frames import RowFrames
from .length import SongLength
-from .modifiers import apply_modifiers
from .rates import EngineRates
from .state import ChannelState
from .synthesizer import RowSynthesizer
-from .timing import SongTiming
-from .voice import SampleVoice
__all__ = [
"ChannelBank",
@@ -14,8 +11,5 @@
"EngineRates",
"RowFrames",
"RowSynthesizer",
- "SampleVoice",
"SongLength",
- "SongTiming",
- "apply_modifiers",
]
diff --git a/src/sampletones_application/logic/sequencer/playback/synthesizer/bank.py b/src/sampletones_application/logic/sequencer/playback/synthesizer/bank.py
index 1e8ab94aa..8db60481a 100644
--- a/src/sampletones_application/logic/sequencer/playback/synthesizer/bank.py
+++ b/src/sampletones_application/logic/sequencer/playback/synthesizer/bank.py
@@ -1,8 +1,8 @@
from typing import Dict
from sampletones_core.configs import Config
-from sampletones_core.constants.enums import GeneratorName
-from sampletones_core.generators.maps import GENERATOR_CLASSES
+from sampletones_core.constants.enums import ChannelName
+from sampletones_core.generators.maps import CHANNEL_CLASSES
from sampletones_core.timing import TickClock
from ..protocol import ChannelGeneratorProtocol
@@ -23,9 +23,9 @@ def __init__(self, config: Config, rates: EngineRates) -> None:
self._config = config
self._rates = rates
self._clock: TickClock = rates.clock()
- self._states: Dict[GeneratorName, ChannelState] = {
- generator_name: ChannelState(generator=generator)
- for generator_name, generator in self._build_generators(rates).items()
+ self._states: Dict[ChannelName, ChannelState] = {
+ channel_name: ChannelState(generator=generator)
+ for channel_name, generator in self._build_generators(rates).items()
}
@property
@@ -33,9 +33,9 @@ def clock(self) -> TickClock:
"""The samples each tick spans at the rates in force."""
return self._clock
- def state(self, generator_name: GeneratorName) -> ChannelState:
- """What ``generator_name`` carries from row to row."""
- return self._states[generator_name]
+ def state(self, channel_name: ChannelName) -> ChannelState:
+ """What ``channel_name`` carries from row to row."""
+ return self._states[channel_name]
def reset(self) -> None:
"""Returns every channel to silence at full volume, as a song starts them."""
@@ -63,20 +63,20 @@ def follow(self, rates: EngineRates) -> None:
self._rates = rates
self._clock = rates.clock()
- for generator_name, generator in self._build_generators(rates).items():
- self._states[generator_name].generator = generator
+ for channel_name, generator in self._build_generators(rates).items():
+ self._states[channel_name].generator = generator
def _build_generators(
self,
rates: EngineRates,
- ) -> Dict[GeneratorName, ChannelGeneratorProtocol]:
+ ) -> Dict[ChannelName, ChannelGeneratorProtocol]:
config = self._engine_config(rates)
return {
- generator_name: GENERATOR_CLASSES[generator_name](
+ channel_name: CHANNEL_CLASSES[channel_name](
config,
- generator_name.value,
+ channel_name.value,
)
- for generator_name in GeneratorName.items()
+ for channel_name in ChannelName.items()
}
def _engine_config(self, rates: EngineRates) -> Config:
diff --git a/src/sampletones_application/logic/sequencer/playback/synthesizer/length.py b/src/sampletones_application/logic/sequencer/playback/synthesizer/length.py
index 718e9ff5c..e75fac730 100644
--- a/src/sampletones_application/logic/sequencer/playback/synthesizer/length.py
+++ b/src/sampletones_application/logic/sequencer/playback/synthesizer/length.py
@@ -2,9 +2,9 @@
from typing import Self
from sampletones_core.project import Project
+from sampletones_core.timing import SongTiming
from .rates import EngineRates
-from .timing import SongTiming
@dataclass(frozen=True)
diff --git a/src/sampletones_application/logic/sequencer/playback/synthesizer/state.py b/src/sampletones_application/logic/sequencer/playback/synthesizer/state.py
index 5af877fe1..9f757221d 100644
--- a/src/sampletones_application/logic/sequencer/playback/synthesizer/state.py
+++ b/src/sampletones_application/logic/sequencer/playback/synthesizer/state.py
@@ -1,53 +1,32 @@
from dataclasses import dataclass, field
-from typing import Dict, Optional
-from sampletones_core.constants.enums import FeatureKey
-from sampletones_core.constants.general import MAX_VOLUME
-from sampletones_core.features import CHANNEL_FEATURE_DEFAULTS
+from sampletones_core.performance import ChannelPerformance
from ..protocol import ChannelGeneratorProtocol
@dataclass
class ChannelState:
- """What one channel carries from row to row.
+ """One channel of the synthesiser: the voice filling its ticks, beside what it carries.
- A pattern states a channel's instrument, transpose, and volume only where it changes them, so
- the channel keeps the last of each until another row states otherwise. The tick index is how
- far into the sounding sample's instructions the channel has played, which is what lets a note
- sustain across rows.
-
- The channel carries a value per envelope dimension too, which is what an instrument leaving a
- dimension to the channel sounds at. A frame the instrument writes hands its value over, so the
- channel keeps the last one written for as long as the song runs.
+ The pattern state is the engine's own (:class:`~sampletones_core.performance.state.ChannelPerformance`),
+ so what a channel is sounding, how far into it, and at what transpose and volume are read
+ the same here as anywhere else a song is played out. The generator is what makes this the
+ audible reading of it: it holds the timer phase across ticks and rows, so a note sustained
+ over several rows keeps one continuous waveform.
Attributes:
generator: The synthesiser filling the channel's ticks.
- sample_id: The sample the channel is sounding, or ``None`` while it is silent.
- tick_index: How many ticks of that sample's instructions the channel has played.
- transpose: The semitone offset a row last set.
- volume: The level a row last set.
- feature_values: The value the channel holds for each envelope dimension.
+ performance: What the channel carries from row to row.
"""
generator: ChannelGeneratorProtocol
- sample_id: Optional[str] = field(default=None)
- tick_index: int = field(default=0)
- transpose: int = field(default=0)
- volume: int = field(default=MAX_VOLUME)
- feature_values: Dict[FeatureKey, int] = field(default_factory=CHANNEL_FEATURE_DEFAULTS.copy)
+ performance: ChannelPerformance = field(default_factory=ChannelPerformance)
def reset(self) -> None:
"""Returns the channel to silence at full volume, as a song starts it.
- The envelope dimensions return to the values a channel holds from the start of a song,
- so a pass through the song sounds the same however the previous one left them.
-
The generator is kept, since it is built from the rates in force rather than from
anything a song reaches.
"""
- self.sample_id = None
- self.tick_index = 0
- self.transpose = 0
- self.volume = MAX_VOLUME
- self.feature_values = CHANNEL_FEATURE_DEFAULTS.copy()
+ self.performance.reset()
diff --git a/src/sampletones_application/logic/sequencer/playback/synthesizer/synthesizer.py b/src/sampletones_application/logic/sequencer/playback/synthesizer/synthesizer.py
index 2e1de2d80..5f2f51063 100644
--- a/src/sampletones_application/logic/sequencer/playback/synthesizer/synthesizer.py
+++ b/src/sampletones_application/logic/sequencer/playback/synthesizer/synthesizer.py
@@ -1,29 +1,22 @@
from dataclasses import replace
-from typing import Callable, FrozenSet, List, Optional, Tuple
+from typing import Callable, FrozenSet, Optional, Tuple
import numpy as np
from sampletones_application.logic.shared.project_source import ProjectSource
from sampletones_core.audio import clip_audio_inplace, silence
from sampletones_core.configs import Config
-from sampletones_core.constants.enums import GeneratorName
-from sampletones_core.constants.general import MAX_VOLUME
-from sampletones_core.instructions import InstructionUnion
+from sampletones_core.constants.enums import ChannelName
+from sampletones_core.performance import VoiceReading, apply_row, resolve_row, sound_tick
from sampletones_core.project import Project
-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.song import Song
from sampletones_core.project.song_position import SongPosition
-from sampletones_core.timing import Groove
+from sampletones_core.timing import Groove, SongTiming
from .bank import ChannelBank
from .frames import RowFrames
-from .modifiers import apply_modifiers
from .rates import EngineRates
from .state import ChannelState
-from .timing import SongTiming
-from .voice import SampleVoice
class RowSynthesizer:
@@ -64,7 +57,7 @@ def __init__(
project_source: ProjectSource,
config: Config,
*,
- active_channels: Callable[[], FrozenSet[GeneratorName]],
+ active_channels: Callable[[], FrozenSet[ChannelName]],
sample_rate: Callable[[], int],
) -> None:
self._project_source = project_source
@@ -154,7 +147,7 @@ def _current_rates(self) -> EngineRates:
)
def _ensure_groove(self, project: Project) -> None:
- """Rebuilds the groove when the row rate or the metre it is spread over changes.
+ """Rebuilds the groove when the row rate or the meter it is spread over changes.
An engine that holds a row for a whole number of ticks reaches a fractional row rate by
varying that number from row to row, and the groove is where those counts are decided.
@@ -176,9 +169,9 @@ def _mix_channels(
channels: ChannelBank,
) -> np.ndarray:
mixed = silence(frames.total)
- for generator_name in GeneratorName.items():
+ for channel_name in ChannelName.items():
channel_audio = self._render_channel(
- generator_name,
+ channel_name,
project,
song,
frames,
@@ -190,125 +183,70 @@ def _mix_channels(
def _render_channel(
self,
- generator_name: GeneratorName,
+ channel_name: ChannelName,
project: Project,
song: Song,
frames: RowFrames,
channels: ChannelBank,
) -> np.ndarray:
- state = channels.state(generator_name)
+ state = channels.state(channel_name)
- row = self._resolve_row(generator_name, song)
- if row is not None:
- self._apply_row_to_state(state, row)
+ row = resolve_row(song, self._position, channel_name)
+ if row is not None and apply_row(state.performance, row):
+ state.generator.reset()
- sample_id = state.sample_id
- if sample_id is None or generator_name not in self._active_channels():
+ voice_id = state.performance.voice_id
+ if voice_id is None or channel_name not in self._active_channels():
return silence(frames.total)
return self._synthesize_ticks(
state,
- sample_id,
+ voice_id,
project,
- generator_name,
+ channel_name,
frames,
)
- def _resolve_row(
- self,
- generator_name: GeneratorName,
- song: Song,
- ) -> Optional[Row]:
- if self._position.order_position >= song.order_length():
- return None
-
- order_entry = song.order[self._position.order_position].get(generator_name)
- if order_entry is None:
- return None
-
- pattern = song.pattern(generator_name, order_entry)
- if pattern is None or self._position.row_index >= len(pattern.rows):
- return None
-
- return pattern.rows[self._position.row_index]
-
- def _apply_row_to_state(self, state: ChannelState, row: Row) -> None:
- match row.command:
- case Instrument() as instrument:
- state.generator.reset()
- state.sample_id = instrument.sample_id
- state.tick_index = 0
- state.transpose = row.transpose if row.transpose is not None else 0
- state.volume = row.volume if row.volume is not None else MAX_VOLUME
- case NoteOff():
- state.generator.reset()
- state.sample_id = None
- state.tick_index = 0
- case None:
- if row.transpose is not None:
- state.transpose = row.transpose
- if row.volume is not None:
- state.volume = row.volume
-
def _synthesize_ticks(
self,
state: ChannelState,
- sample_id: str,
+ voice_id: str,
project: Project,
- generator_name: GeneratorName,
+ channel_name: ChannelName,
frames: RowFrames,
) -> np.ndarray:
- sample = project.sample(sample_id)
- if sample is None:
+ voice = project.voice(voice_id)
+ reading = VoiceReading.read(voice, channel_name) if voice is not None else None
+ if reading is None:
return silence(frames.total)
- instructions = sample.reconstruction.instructions[generator_name]
- if not instructions:
- return silence(frames.total)
-
- voice = SampleVoice.read(sample.reconstruction, generator_name)
output = silence(frames.total)
silence_frame = silence(frames.longest)
for tick, frame_length in enumerate(frames.lengths):
frame = self._synthesize_tick(
state,
- instructions,
+ reading,
silence_frame[:frame_length],
- sample.loop,
frame_length,
- voice,
)
output[frames.bounds[tick] : frames.bounds[tick + 1]] = frame
- state.tick_index += 1
return output
def _synthesize_tick(
self,
state: ChannelState,
- instructions: List[InstructionUnion],
+ reading: VoiceReading,
silence_frame: np.ndarray,
- loop: bool,
frame_length: int,
- voice: SampleVoice,
) -> np.ndarray:
- if loop:
- instruction = instructions[state.tick_index % len(instructions)]
- elif state.tick_index < len(instructions):
- instruction = instructions[state.tick_index]
- else:
+ instruction = sound_tick(state.performance, reading)
+ if instruction is None:
return silence_frame
state.generator.frame_length = frame_length
- return state.generator(
- apply_modifiers(
- voice.sound(instruction, state.feature_values),
- state.transpose,
- state.volume,
- ),
- save=True,
- )
+ return state.generator(instruction, save=True)
def _advance_position(self, song: Song) -> None:
self._position.advance(song.rows_per_pattern, song.order_length())
diff --git a/src/sampletones_application/logic/sequencer/playback/synthesizer/voice.py b/src/sampletones_application/logic/sequencer/playback/synthesizer/voice.py
deleted file mode 100644
index 51b348328..000000000
--- a/src/sampletones_application/logic/sequencer/playback/synthesizer/voice.py
+++ /dev/null
@@ -1,84 +0,0 @@
-from __future__ import annotations
-
-from dataclasses import dataclass
-from typing import Dict, Tuple
-
-from sampletones_core.constants.enums import FeatureKey, GeneratorName
-from sampletones_core.exporters import GENERATOR_NAME_TO_EXPORTER_MAP, ExporterTypeUnion
-from sampletones_core.instructions import InstructionUnion
-from sampletones_core.reconstructions import Reconstruction
-
-
-@dataclass(frozen=True)
-class SampleVoice:
- """How one channel reads a sample's frames.
-
- A sample carries a frame per tick stating every dimension the channel reads, and the
- reconstruction names which of those dimensions the instrument itself wrote. The rest are the
- channel's own: the instrument leaves an empty envelope for them and the channel sounds them at
- the value it holds, which is what clearing an envelope in the instruments panel means once the
- sample is played in a song.
-
- Attributes:
- exporter: The reading that turns this channel's frames into envelope values and back.
- initial_pitch: Reference pitch the arpeggio values are measured against.
- held_features: The dimensions the instrument leaves to the channel.
- """
-
- exporter: ExporterTypeUnion
- initial_pitch: int
- held_features: Tuple[FeatureKey, ...]
-
- @classmethod
- def read(
- cls,
- reconstruction: Reconstruction,
- generator_name: GeneratorName,
- ) -> SampleVoice:
- """The voice one channel of ``reconstruction`` is played through.
-
- Args:
- reconstruction: The sample's reconstruction.
- generator_name: The channel being sounded.
-
- Returns:
- SampleVoice: The reading of that channel's frames.
- """
- return cls(
- exporter=GENERATOR_NAME_TO_EXPORTER_MAP[generator_name],
- initial_pitch=reconstruction.initial_pitches[generator_name],
- held_features=reconstruction.held_features[generator_name],
- )
-
- def sound(
- self,
- instruction: InstructionUnion,
- feature_values: Dict[FeatureKey, int],
- ) -> InstructionUnion:
- """The frame the channel sounds, once the dimensions it governs are filled in.
-
- ``feature_values`` is the channel's own, and this is where it moves: the dimensions the
- frame states and the instrument writes are handed over to it, and every dimension the
- frame plays is then read back out of it. So an instrument that writes a dimension sets
- what the channel holds, and one that leaves it empty sounds at what the channel holds.
-
- Args:
- instruction: The frame as the sample holds it.
- feature_values: The values the channel holds, updated with what the instrument writes.
-
- Returns:
- InstructionUnion: The frame to sound, before the pattern's transpose and volume.
- """
- stated = self.exporter.feature_values(
- instruction, # type: ignore[arg-type]
- self.initial_pitch,
- )
- for feature_key, value in stated.items():
- if feature_key not in self.held_features:
- feature_values[feature_key] = value
-
- sounded: InstructionUnion = self.exporter.instruction_from_values(
- feature_values,
- self.initial_pitch,
- )
- return sounded
diff --git a/src/sampletones_application/logic/sequencer/samples.py b/src/sampletones_application/logic/sequencer/samples.py
deleted file mode 100644
index 49051bf3c..000000000
--- a/src/sampletones_application/logic/sequencer/samples.py
+++ /dev/null
@@ -1,178 +0,0 @@
-from typing import Callable, Optional
-
-from sampletones_application.config.managers.session import SessionManager
-from sampletones_application.layout.behavior.scheduling.scheduling import (
- SchedulingBehavior,
-)
-from sampletones_application.logic.project.controller import ProjectController
-from sampletones_application.logic.shared.playback_priority import PlaybackPriority
-from sampletones_application.utils.callbacks.queue import CallbackQueue
-from sampletones_application.view_model.sequencer.samples import (
- SampleEntryViewModel,
- SequencerSamplesViewModel,
-)
-from sampletones_application.view_model.shared.footprint import SampleFootprintViewModel
-from sampletones_core.audio import AudioDeviceManager
-from sampletones_core.formats.famitracker.footprint import reconstruction_footprints
-from sampletones_core.project.instruments.sample import Sample
-from sampletones_core.reconstructions import Reconstruction
-from sampletones_core.utils.display import display_sample
-from sampletones_shared.exceptions import PlaybackError
-from sampletones_shared.logger import logger
-from sampletones_shared.types.callback import StringCallback
-from sampletones_shared.utils.callbacks import CallbackMixin
-
-
-class SequencerSamplesLogic(CallbackMixin):
- """Drives the samples panel: lists the pool, edits it, and previews samples.
-
- Every pool edit goes through the controller so the project stays the single
- source of truth. ``on_edit_sample_requested`` hands a sample id to the
- application, which opens that sample's reconstruction in the Reconstruction
- tab for live-linked editing.
-
- Previewing mirrors the reconstruction browser: a single click schedules a
- debounced autoplay that fires only when the session's autoplay flag is on, and
- a double-click (edit) cancels the pending preview before it plays.
- """
-
- def __init__(
- self,
- project_controller: ProjectController,
- session_manager: SessionManager,
- audio_device_manager: AudioDeviceManager,
- *,
- scheduling: SchedulingBehavior,
- ) -> None:
- self._controller = project_controller
- self._session_manager = session_manager
- self._audio_device_manager = audio_device_manager
- self._scheduling = scheduling
- self._pending_autoplay_sample: Optional[str] = None
-
- self.on_samples_changed: Optional[Callable[[SequencerSamplesViewModel], None]] = None
- self.on_edit_sample_requested: Optional[StringCallback] = None
- self.on_autoplay_error: Optional[Callable[[Exception], None]] = None
-
- def build_samples(self) -> SequencerSamplesViewModel:
- entries = tuple(
- SampleEntryViewModel(
- sample_id=sample.id,
- name=sample.name,
- loop=sample.loop,
- )
- for sample in self._controller.project.samples
- )
- return SequencerSamplesViewModel(samples=entries)
-
- def push_samples(self) -> None:
- self.call(self.on_samples_changed, self.build_samples())
-
- def add_sample(self, reconstruction: Reconstruction, name: str) -> Sample:
- return self._controller.add_sample(reconstruction, name)
-
- def rename_sample(self, sample_id: str, name: str) -> None:
- self._controller.rename_sample(sample_id, name)
-
- def is_sample_used(self, sample_id: str) -> bool:
- return self._controller.is_sample_used(sample_id)
-
- def build_sample_footprint(self, sample_id: str) -> Optional[SampleFootprintViewModel]:
- """Measures one sample's instruments as the module export writes them.
-
- A sample carries its own loop flag, and a looping instrument is compiled to the shortest
- length its envelopes share, so the sample is measured the way it is placed. Measuring a
- single sample on demand keeps a pool edit clear of an export it was not asked for.
-
- Args:
- sample_id: The sample to measure.
-
- Returns:
- Optional[SampleFootprintViewModel]: The sample's byte figures, or ``None`` while the
- pool holds no such sample.
- """
- sample = self._controller.project.samples.get(sample_id)
- if sample is None:
- return None
-
- return SampleFootprintViewModel.from_footprints(
- reconstruction_footprints(sample.reconstruction, loop=sample.loop)
- )
-
- def sample_name(self, sample_id: str) -> str:
- return self._controller.project.samples[sample_id].name
-
- def sample_position(self, sample_id: str) -> str:
- """Returns the sample's hex list position, matching how the tracker labels it."""
- return display_sample(
- samples=self._controller.project.samples,
- sample_id=sample_id,
- )
-
- def remove_sample(self, sample_id: str) -> None:
- self._controller.remove_sample(sample_id)
-
- def move_sample(self, sample_id: str, to_index: int) -> None:
- self._controller.move_sample(sample_id, to_index)
-
- def duplicate_sample(self, sample_id: str) -> None:
- self._controller.duplicate_sample(sample_id)
-
- def set_sample_loop(self, sample_id: str, loop: bool) -> None:
- self._controller.set_sample_loop(sample_id, loop)
-
- def request_edit(self, sample_id: str) -> None:
- self.cancel_autoplay()
- self.call(self.on_edit_sample_requested, sample_id)
-
- def play_sample(self, sample_id: str) -> None:
- """Plays a sample on demand, regardless of the autoplay setting.
-
- Explicit playback is intentional, so it uses ``NORMAL`` priority and thereby
- preempts the sequencer song / reconstruction players.
- """
- self._play_sample(sample_id, priority=PlaybackPriority.NORMAL)
-
- def request_autoplay(self, sample_id: str) -> None:
- """Schedules a debounced preview that a following double-click can cancel."""
- self._pending_autoplay_sample = sample_id
- CallbackQueue.add(
- self._execute_autoplay,
- priority=self._scheduling.priorities.schedule,
- delay=self._scheduling.delays.schedule,
- )
-
- def cancel_autoplay(self) -> None:
- self._pending_autoplay_sample = None
-
- def _execute_autoplay(self) -> None:
- if self._pending_autoplay_sample is None:
- return
-
- sample_id = self._pending_autoplay_sample
- self._pending_autoplay_sample = None
- if self._session_manager.autoplay:
- self._play_sample(sample_id, priority=PlaybackPriority.PREVIEW)
-
- def _play_sample(
- self,
- sample_id: str,
- *,
- priority: PlaybackPriority,
- ) -> None:
- sample = self._controller.project.samples.get(sample_id)
- if sample is None:
- return
-
- try:
- self._audio_device_manager.play(
- sample.reconstruction.approximation,
- update=False,
- priority=priority,
- )
- except (PlaybackError, ValueError) as exception:
- logger.error_with_traceback(
- exception,
- f"Failed to preview sample: {sample_id}",
- )
- self.call(self.on_autoplay_error, exception)
diff --git a/src/sampletones_application/logic/sequencer/tracker/adjuster.py b/src/sampletones_application/logic/sequencer/tracker/adjuster.py
index 3e77e4b5f..aa9681c72 100644
--- a/src/sampletones_application/logic/sequencer/tracker/adjuster.py
+++ b/src/sampletones_application/logic/sequencer/tracker/adjuster.py
@@ -1,7 +1,7 @@
from typing import Iterator, List, Optional, Tuple
from sampletones_application.view_model.sequencer.region import TrackerRegion
-from sampletones_core.constants.enums import GeneratorName
+from sampletones_core.constants.enums import ChannelName
from .tracker import SequencerTrackerLogic
@@ -23,34 +23,34 @@ def __init__(self, tracker_logic: SequencerTrackerLogic) -> None:
def adjust_transpose(self, region: TrackerRegion, delta: int) -> None:
"""Shifts every covered cell's transpose by ``delta`` semitones."""
- for row_index, generator in self._cells(region):
- self._tracker.adjust_transpose(generator, row_index, delta)
+ for row_index, channel in self._cells(region):
+ self._tracker.adjust_transpose(channel, row_index, delta)
def adjust_volume(self, region: TrackerRegion, delta: int) -> None:
"""Shifts every covered cell's volume by ``delta``."""
- for row_index, generator in self._cells(region):
- self._tracker.adjust_volume(generator, row_index, delta)
+ for row_index, channel in self._cells(region):
+ self._tracker.adjust_volume(channel, row_index, delta)
def _cells(
self,
region: TrackerRegion,
- ) -> Iterator[Tuple[int, GeneratorName]]:
+ ) -> Iterator[Tuple[int, ChannelName]]:
"""The channel cells a region reaches, row by row and each named once."""
columns = region.columns
for row_index in region.rows:
- for generator in self._channels(columns, row_index):
- yield row_index, generator
+ for channel in self._channels(columns, row_index):
+ yield row_index, channel
def _channels(
self,
- columns: Tuple[Optional[GeneratorName], ...],
+ columns: Tuple[Optional[ChannelName], ...],
row_index: int,
- ) -> List[GeneratorName]:
+ ) -> List[ChannelName]:
"""The channels a row's columns reach, the sample column standing for the ones it governs."""
- channels: List[GeneratorName] = []
+ channels: List[ChannelName] = []
for column in columns:
if column is None:
- channels.extend(self._tracker.relevant_generators(row_index))
+ channels.extend(self._tracker.relevant_channels(row_index))
else:
channels.append(column)
diff --git a/src/sampletones_application/logic/sequencer/tracker/block.py b/src/sampletones_application/logic/sequencer/tracker/block.py
index a55019326..fc223e39f 100644
--- a/src/sampletones_application/logic/sequencer/tracker/block.py
+++ b/src/sampletones_application/logic/sequencer/tracker/block.py
@@ -1,7 +1,7 @@
from dataclasses import dataclass
from typing import Dict, Optional, Tuple, Union
-from sampletones_core.project.instruments.note_off import NoteOff
+from sampletones_core.project.voices.note_off import NoteOff
BlockNote = Union[str, NoteOff]
BlockKey = Tuple[int, int]
diff --git a/src/sampletones_application/logic/sequencer/tracker/reader.py b/src/sampletones_application/logic/sequencer/tracker/reader.py
index c0ef980dc..1576c85ac 100644
--- a/src/sampletones_application/logic/sequencer/tracker/reader.py
+++ b/src/sampletones_application/logic/sequencer/tracker/reader.py
@@ -7,10 +7,10 @@
slot_from_flat,
)
from sampletones_application.view_model.sequencer.subcolumn import SubColumn
-from sampletones_core.constants.enums import GeneratorName
-from sampletones_core.project.instruments.instrument import Instrument
-from sampletones_core.project.instruments.note_off import NoteOff
+from sampletones_core.constants.enums import ChannelName
from sampletones_core.project.patterns.row import Row
+from sampletones_core.project.voices.note_off import NoteOff
+from sampletones_core.project.voices.note_on import NoteOn
from sampletones_shared.utils.agreement import Agreement
from .block import BlockKey, BlockNote, TrackerBlock
@@ -31,9 +31,9 @@ def __init__(self, tracker_logic: SequencerTrackerLogic) -> None:
def read(self, region: TrackerRegion) -> TrackerBlock:
"""Takes the values a region covers, keeping each kind of subcolumn in a map of its own."""
- base = column_slot_base(slot_from_flat(region.first_slot).generator)
+ base = column_slot_base(slot_from_flat(region.first_slot).channel)
return TrackerBlock(
- notes=self._read_subcolumn(region, base, SubColumn.INSTRUMENT, self._note_of),
+ notes=self._read_subcolumn(region, base, SubColumn.VOICE, self._note_of),
transposes=self._read_subcolumn(region, base, SubColumn.TRANSPOSE, self._transpose_of),
volumes=self._read_subcolumn(region, base, SubColumn.VOLUME, self._volume_of),
)
@@ -57,29 +57,40 @@ def _read_subcolumn(
if slot.subcolumn is not subcolumn:
continue
- agreement = self._agree(row_index, slot.generator, select)
+ agreement = self._agree(row_index, slot.channel, select)
if agreement.is_unanimous:
- values[(row_offset, region.first_slot + position - base)] = agreement.value
+ values[
+ (
+ row_offset,
+ region.first_slot + position - base,
+ )
+ ] = agreement.value
return values
def _agree(
self,
row_index: int,
- generator: Optional[GeneratorName],
+ channel: Optional[ChannelName],
select: Callable[[Optional[Row]], ValueT],
) -> Agreement[ValueT]:
"""What a column holds at a cell: a channel's own value, or the one its channels share.
A channel column answers for itself, so it is a group of one and always agrees. The sample
- column answers for the channels it governs, which is the group its display summarises too,
+ column answers for the channels it governs, which is the group its display summarizes too,
so a block states about a cell exactly what the grid it came from shows there.
"""
- if generator is not None:
- return Agreement.collapse([select(self._tracker.row(generator, row_index))])
+ if channel is not None:
+ return Agreement.collapse([select(self._tracker.row(channel, row_index))])
return Agreement.collapse(
- select(self._tracker.row(channel, row_index)) for channel in self._tracker.relevant_generators(row_index)
+ select(
+ self._tracker.row(
+ channel,
+ row_index,
+ )
+ )
+ for channel in self._tracker.relevant_channels(row_index)
)
@staticmethod
@@ -90,8 +101,8 @@ def _note_of(row: Optional[Row]) -> Optional[BlockNote]:
column it is written into.
"""
match row.command if row is not None else None:
- case Instrument() as instrument:
- return instrument.sample_id
+ case NoteOn() as note_on:
+ return note_on.voice_id
case NoteOff() as note_off:
return note_off
case None:
diff --git a/src/sampletones_application/logic/sequencer/tracker/tracker.py b/src/sampletones_application/logic/sequencer/tracker/tracker.py
index b9ad587ff..709633b18 100644
--- a/src/sampletones_application/logic/sequencer/tracker/tracker.py
+++ b/src/sampletones_application/logic/sequencer/tracker/tracker.py
@@ -1,6 +1,9 @@
from typing import Callable, Dict, FrozenSet, List, Optional, Set
from sampletones_application.logic.project.controller import ProjectController
+from sampletones_application.view_model.sequencer.kind import (
+ voice_kind,
+)
from sampletones_application.view_model.sequencer.settings import (
SequencerSettingsViewModel,
)
@@ -10,25 +13,30 @@
SequencerRowViewModel,
SequencerTrackerViewModel,
)
-from sampletones_core.constants.enums import GeneratorName
+from sampletones_application.view_model.sequencer.voices import VoiceKind
+from sampletones_core.constants.enums import ChannelName
from sampletones_core.constants.general import MAX_VOLUME
-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.pattern import Pattern
from sampletones_core.project.patterns.row import NoteCommand, Row
+from sampletones_core.project.voices.instrument import Instrument
+from sampletones_core.project.voices.note_off import NoteOff
+from sampletones_core.project.voices.note_on import NoteOn
+from sampletones_core.project.voices.sample import Sample
+from sampletones_core.project.voices.voice import VoiceUnion, voice_channels, voice_reference
from sampletones_core.utils.display import (
display_command,
display_id,
+ display_note,
display_transpose,
display_volume,
)
from sampletones_shared.utils.callbacks import CallbackMixin
_EMPTY_CELL = SequencerCellViewModel(
- instrument=display_id(None),
+ voice=display_id(None),
transpose=display_transpose(None),
volume=display_volume(None),
+ kind=None,
)
@@ -40,8 +48,8 @@ class SequencerTrackerLogic(CallbackMixin):
controller's change events are wired (by the coordinator) back to the push
methods here, so a single mutation round-trips into a refreshed view.
- Cell-level edits take an ``Optional[GeneratorName]`` naming the column they
- address: a generator reaches that channel alone, while ``None`` addresses the
+ Cell-level edits take an ``Optional[ChannelName]`` naming the column they
+ address: a channel reaches that channel alone, while ``None`` addresses the
sample column and spreads the edit over the channels that column governs.
"""
@@ -72,7 +80,8 @@ def build_grid(self) -> SequencerTrackerViewModel:
frame_index = self._clamp_frame(frame_count)
patterns = self._frame_patterns()
- rows = tuple(self._build_row(index, patterns) for index in range(self.frame_row_count()))
+ carried: Dict[ChannelName, Optional[VoiceUnion]] = {channel: None for channel in ChannelName.items()}
+ rows = tuple(self._build_row(index, patterns, carried) for index in range(self.frame_row_count()))
return SequencerTrackerViewModel(
frame_index=frame_index,
frame_count=frame_count,
@@ -98,7 +107,7 @@ def frame_row_count(self) -> int:
return song.rows_per_pattern
- def _frame_patterns(self) -> Dict[GeneratorName, Pattern]:
+ def _frame_patterns(self) -> Dict[ChannelName, Pattern]:
"""The patterns the current frame's channels point at.
A channel contributes an entry once its slot names a pattern the song holds,
@@ -108,12 +117,12 @@ def _frame_patterns(self) -> Dict[GeneratorName, Pattern]:
if self._frame_index >= song.order_length():
return {}
- patterns: Dict[GeneratorName, Pattern] = {}
- for generator in GeneratorName.items():
- index = song.order[self._frame_index].get(generator)
- pattern = song.pattern(generator, index) if index is not None else None
+ patterns: Dict[ChannelName, Pattern] = {}
+ for channel in ChannelName.items():
+ index = song.order[self._frame_index].get(channel)
+ pattern = song.pattern(channel, index) if index is not None else None
if pattern is not None:
- patterns[generator] = pattern
+ patterns[channel] = pattern
return patterns
@@ -144,38 +153,38 @@ def set_speed(self, speed: int) -> None:
def clear_cell(
self,
row_index: int,
- generator: Optional[GeneratorName],
+ channel: Optional[ChannelName],
) -> None:
- if generator is None:
- self.clear_all_generators(row_index)
+ if channel is None:
+ self.clear_all_channels(row_index)
else:
- self.clear_row(generator, row_index)
+ self.clear_row(channel, row_index)
def clear_cell_subcolumn(
self,
row_index: int,
- generator: Optional[GeneratorName],
+ channel: Optional[ChannelName],
subcolumn: SubColumn,
) -> None:
"""Empties one subcolumn of a cell.
- From the sample column an instrument reaches every channel, since the sample
+ From the sample column the voice slot reaches every channel, since the sample
it names is the row's whole note, while transpose and volume follow the
channels that column governs.
"""
- instrument = subcolumn is SubColumn.INSTRUMENT
+ voice = subcolumn is SubColumn.VOICE
transpose = subcolumn is SubColumn.TRANSPOSE
volume = subcolumn is SubColumn.VOLUME
- if generator is not None:
+ if channel is not None:
self.clear_subcolumn(
- generator,
+ channel,
row_index,
- instrument=instrument,
+ voice=voice,
transpose=transpose,
volume=volume,
)
- elif instrument:
- self.clear_subcolumn_all_generators(row_index, instrument=True)
+ elif voice:
+ self.clear_subcolumn_all_generators(row_index, voice=True)
else:
self.clear_sample_subcolumn(
row_index,
@@ -186,8 +195,8 @@ def clear_cell_subcolumn(
def write_cell(
self,
row_index: int,
- generator: Optional[GeneratorName],
- sample_id: Optional[str],
+ channel: Optional[ChannelName],
+ voice_id: Optional[str],
transpose: Optional[int],
volume: Optional[int],
) -> None:
@@ -196,12 +205,12 @@ def write_cell(
An edit names one subcolumn, so a sample takes the write whenever one
arrives, and an offset lands on its own otherwise.
"""
- if sample_id is not None:
- self.place_note(row_index, generator, sample_id)
+ if voice_id is not None:
+ self.place_note(row_index, channel, voice_id)
elif transpose is not None or volume is not None:
self.set_cell_subcolumn(
row_index,
- generator,
+ channel,
transpose=transpose,
volume=volume,
)
@@ -209,40 +218,55 @@ def write_cell(
def place_note(
self,
row_index: int,
- generator: Optional[GeneratorName],
- sample_id: str,
+ channel: Optional[ChannelName],
+ voice_id: str,
) -> None:
- if generator is None:
- self.set_sample_instrument(row_index, sample_id)
+ """Places a voice on the cell a column and a row name, where that column takes it.
+
+ Every route that names a voice for a cell arrives here — a typed number, a menu item and a
+ pasted block alike — so the sample column's rule is asked once and each of them follows it.
+ """
+ if channel is None:
+ if self.places_in_sample_column(voice_id):
+ self.set_row_sample(row_index, voice_id)
else:
self.set_row(
- generator,
+ channel,
row_index,
- command=Instrument(
- sample_id=sample_id,
- generator_name=generator,
- ),
+ command=NoteOn(voice_id=voice_id),
)
+ def places_in_sample_column(self, voice_id: str) -> bool:
+ """Whether the sample column takes the voice an id names.
+
+ The column spreads a voice over the channels it covers, which a recording states for
+ itself, so it answers for a sample the project holds and stands by for anything else.
+ """
+ voice = self._controller.project.voices.get(voice_id)
+ if voice is None:
+ return False
+
+ return voice_kind(voice).places_across_channels
+
def cut_note(
self,
row_index: int,
- generator: Optional[GeneratorName],
+ channel: Optional[ChannelName],
) -> None:
- if generator is None:
+ if channel is None:
self.set_note_off_all_generators(row_index)
else:
- self.set_note_off(generator, row_index)
+ self.set_note_off(channel, row_index)
def set_cell_subcolumn(
self,
row_index: int,
- generator: Optional[GeneratorName],
+ channel: Optional[ChannelName],
*,
transpose: Optional[int] = None,
volume: Optional[int] = None,
) -> None:
- if generator is None:
+ if channel is None:
self.set_sample_subcolumn(
row_index,
transpose=transpose,
@@ -250,7 +274,7 @@ def set_cell_subcolumn(
)
else:
self.set_row(
- generator,
+ channel,
row_index,
transpose=transpose,
volume=volume,
@@ -258,22 +282,22 @@ def set_cell_subcolumn(
def set_row(
self,
- generator: GeneratorName,
+ channel: ChannelName,
row_index: int,
*,
command: Optional[NoteCommand] = None,
transpose: Optional[int] = None,
volume: Optional[int] = None,
) -> None:
- pattern_index = self._pattern_index_at_frame(generator)
+ pattern_index = self._pattern_index_at_frame(channel)
if pattern_index is None:
- pattern_index = self._create_frame_pattern(generator)
+ pattern_index = self._create_frame_pattern(channel)
if pattern_index is None:
return
self._controller.update_row(
- generator,
+ channel,
pattern_index,
row_index,
command=command,
@@ -281,98 +305,148 @@ def set_row(
volume=volume,
)
- def clear_row(self, generator: GeneratorName, row_index: int) -> None:
- pattern_index = self._pattern_index_at_frame(generator)
+ def clear_row(self, channel: ChannelName, row_index: int) -> None:
+ pattern_index = self._pattern_index_at_frame(channel)
if pattern_index is None:
return
- self._controller.clear_row(generator, pattern_index, row_index)
+ self._controller.clear_row(channel, pattern_index, row_index)
def clear_subcolumn(
self,
- generator: GeneratorName,
+ channel: ChannelName,
row_index: int,
*,
- instrument: bool = False,
+ voice: bool = False,
transpose: bool = False,
volume: bool = False,
) -> None:
- pattern_index = self._pattern_index_at_frame(generator)
+ pattern_index = self._pattern_index_at_frame(channel)
if pattern_index is None:
return
self._controller.clear_row(
- generator,
+ channel,
pattern_index,
row_index,
- instrument=instrument,
+ voice=voice,
transpose=transpose,
volume=volume,
)
- def clear_all_generators(self, row_index: int) -> None:
- for generator in GeneratorName.items():
- self.clear_row(generator, row_index)
+ def clear_all_channels(self, row_index: int) -> None:
+ for channel in ChannelName.items():
+ self.clear_row(channel, row_index)
def clear_subcolumn_all_generators(
self,
row_index: int,
*,
- instrument: bool = False,
+ voice: bool = False,
transpose: bool = False,
volume: bool = False,
) -> None:
- for generator in GeneratorName.items():
+ for channel in ChannelName.items():
self.clear_subcolumn(
- generator,
+ channel,
row_index,
- instrument=instrument,
+ voice=voice,
transpose=transpose,
volume=volume,
)
- def set_sample_instrument(
+ def set_row_sample(
self,
row_index: int,
- sample_id: Optional[str],
+ voice_id: Optional[str],
) -> None:
"""Places a sample across the channels its reconstruction uses.
- The sample column is authoritative: the instrument is written to every
- generator the sample covers, and the remaining channels on that row are
- cleared so the row reflects exactly that sample. Clearing an empty sample
- id wipes the whole row.
+ The sample column is authoritative: the sample is written to every channel it covers, and
+ the remaining channels on that row are cleared so the row plays exactly that sample.
+ An empty voice id wipes the whole row.
+
+ The column speaks for samples, which carry a slice per channel. A hand-written voice sounds
+ on whichever channel the reader names it in, so it is placed in a channel column and this
+ one leaves the row as it stands.
"""
- if sample_id is None:
- self.clear_all_generators(row_index)
+ if voice_id is None:
+ self.clear_all_channels(row_index)
return
- sample = self._controller.project.samples.get(sample_id)
- if sample is None:
+ sample = self._controller.project.voices.get(voice_id)
+ if not isinstance(sample, Sample):
return
used = self._used_generators(sample)
- for generator in GeneratorName.items():
- if generator in used:
+ for channel in ChannelName.items():
+ if channel in used:
self.set_row(
- generator,
+ channel,
row_index,
- command=Instrument(
- sample_id=sample_id,
- generator_name=generator,
- ),
+ command=NoteOn(voice_id=voice_id),
)
else:
- self.clear_row(generator, row_index)
+ self.clear_row(channel, row_index)
+
+ def write_note(
+ self,
+ row_index: int,
+ channel: ChannelName,
+ pitch: int,
+ ) -> None:
+ """Writes the step that reaches ``pitch`` on the voice this channel is carrying.
+
+ A note key names the note the reader wants to hear; the row states it as the step from the
+ voice's own reference, which is the one number a pitch cell holds. A row carrying no voice
+ has nothing to measure the note against, so the press leaves it as it stands.
+
+ Args:
+ row_index: The row within the frame shown.
+ channel: The channel the note is typed into.
+ pitch: The note the key names, on the scale the channel reads.
+ """
+ voice = self.carried_voice(channel, row_index)
+ if voice is None:
+ return
+
+ self.write_cell(
+ row_index,
+ channel,
+ None,
+ pitch - voice_reference(voice, channel),
+ None,
+ )
+
+ def carried_voice(
+ self,
+ channel: ChannelName,
+ row_index: int,
+ ) -> Optional[VoiceUnion]:
+ """The voice a channel is carrying at a row of the frame shown.
+
+ A row may bend a note it did not start, so the answer is found by reading down the frame's
+ rows to this one, the way the grid reads its pitch column.
+ """
+ pattern_index = self._pattern_index_at_frame(channel)
+ pattern = self._controller.project.song.pattern(channel, pattern_index) if pattern_index is not None else None
+ if pattern is None:
+ return None
+
+ carried: Optional[VoiceUnion] = None
+ for row in pattern.rows[: row_index + 1]:
+ carried = self._carried_voice(row, carried)
- def set_note_off(self, generator: GeneratorName, row_index: int) -> None:
+ return carried
+
+ def set_note_off(self, channel: ChannelName, row_index: int) -> None:
"""Writes a note-off into one channel's cell, materialising the pattern if needed."""
- self.set_row(generator, row_index, command=NoteOff())
+ self.set_row(channel, row_index, command=NoteOff())
def set_note_off_all_generators(self, row_index: int) -> None:
"""Cuts every channel at this row, the sample-column counterpart of :meth:`set_note_off`."""
- for generator in GeneratorName.items():
- self.set_note_off(generator, row_index)
+ for channel in ChannelName.items():
+ self.set_note_off(channel, row_index)
def set_sample_subcolumn(
self,
@@ -381,15 +455,15 @@ def set_sample_subcolumn(
transpose: Optional[int] = None,
volume: Optional[int] = None,
) -> None:
- """Synchronises a subcolumn across the row's relevant channels.
+ """Synchronizes a subcolumn across the row's relevant channels.
- Transpose and volume exist independently of an instrument: they follow the
+ Transpose and volume exist independently of the voice slot: they follow the
sample's channels when one is present, and otherwise reach every channel, so
a value typed in the sample column always lands somewhere.
"""
- for generator in self._subcolumn_generators(row_index):
+ for channel in self._subcolumn_generators(row_index):
self.set_row(
- generator,
+ channel,
row_index,
transpose=transpose,
volume=volume,
@@ -402,9 +476,9 @@ def clear_sample_subcolumn(
transpose: bool = False,
volume: bool = False,
) -> None:
- for generator in self._subcolumn_generators(row_index):
+ for channel in self._subcolumn_generators(row_index):
self.clear_subcolumn(
- generator,
+ channel,
row_index,
transpose=transpose,
volume=volume,
@@ -412,7 +486,7 @@ def clear_sample_subcolumn(
def adjust_transpose(
self,
- generator: GeneratorName,
+ channel: ChannelName,
row_index: int,
delta: int,
) -> None:
@@ -422,14 +496,14 @@ def adjust_transpose(
``delta``; the controller clamps the result to the transpose range.
"""
self.set_row(
- generator,
+ channel,
row_index,
- transpose=self._current_transpose(generator, row_index) + delta,
+ transpose=self._current_transpose(channel, row_index) + delta,
)
def adjust_volume(
self,
- generator: GeneratorName,
+ channel: ChannelName,
row_index: int,
delta: int,
) -> None:
@@ -439,22 +513,22 @@ def adjust_volume(
the first decrement steps down from the maximum.
"""
self.set_row(
- generator,
+ channel,
row_index,
- volume=self._current_volume(generator, row_index) + delta,
+ volume=self._current_volume(channel, row_index) + delta,
)
def row(
self,
- generator: GeneratorName,
+ channel: ChannelName,
row_index: int,
) -> Optional[Row]:
"""The row stored at a cell, present while its channel holds a pattern reaching that far."""
- pattern_index = self._pattern_index_at_frame(generator)
+ pattern_index = self._pattern_index_at_frame(channel)
if pattern_index is None:
return None
- pattern = self._controller.project.song.pattern(generator, pattern_index)
+ pattern = self._controller.project.song.pattern(channel, pattern_index)
if pattern is None or row_index >= pattern.length:
return None
@@ -462,17 +536,17 @@ def row(
def _current_transpose(
self,
- generator: GeneratorName,
+ channel: ChannelName,
row_index: int,
) -> int:
- row = self.row(generator, row_index)
+ row = self.row(channel, row_index)
if row is None or row.transpose is None:
return 0
return row.transpose
- def _current_volume(self, generator: GeneratorName, row_index: int) -> int:
- row = self.row(generator, row_index)
+ def _current_volume(self, channel: ChannelName, row_index: int) -> int:
+ row = self.row(channel, row_index)
if row is None or row.volume is None:
return MAX_VOLUME
@@ -486,19 +560,19 @@ def select_frame(self, frame_index: int) -> None:
self._frame_index = frame_index
self.push_tracker()
- def holds_sample(self, sample_id: str) -> bool:
- """Whether the project holds the sample a note names, which is what makes the note placeable."""
- return self._controller.project.samples.get(sample_id) is not None
+ def holds_voice(self, voice_id: str) -> bool:
+ """Whether the project holds the voice a note names, which is what makes the note placeable."""
+ return self._controller.project.voices.get(voice_id) is not None
- def used_generators(self, sample_id: str) -> List[GeneratorName]:
+ def used_generators(self, voice_id: str) -> List[ChannelName]:
"""The channels a sample provides instructions for, empty when it is unknown."""
- sample = self._controller.project.samples.get(sample_id)
+ sample = self._controller.project.voices.get(voice_id)
if sample is None:
return []
return self._used_generators(sample)
- def relevant_generators(self, row_index: int) -> List[GeneratorName]:
+ def relevant_channels(self, row_index: int) -> List[ChannelName]:
"""The channels a sample-column subcolumn edit reaches at ``row_index``.
Follows the row's sample channels when one governs it, and otherwise every
@@ -506,14 +580,14 @@ def relevant_generators(self, row_index: int) -> List[GeneratorName]:
"""
return self._subcolumn_generators(row_index)
- def _pattern_index_at_frame(self, generator: GeneratorName) -> Optional[int]:
+ def _pattern_index_at_frame(self, channel: ChannelName) -> Optional[int]:
song = self._controller.project.song
if self._frame_index < song.order_length():
- return song.order[self._frame_index].get(generator)
+ return song.order[self._frame_index].get(channel)
return None
- def _create_frame_pattern(self, generator: GeneratorName) -> Optional[int]:
+ def _create_frame_pattern(self, channel: ChannelName) -> Optional[int]:
"""Materialises a pattern for an empty slot at the current frame, on first edit.
Providing content to a channel whose current frame is an empty (None) slot
@@ -525,120 +599,184 @@ def _create_frame_pattern(self, generator: GeneratorName) -> Optional[int]:
if self._frame_index >= song.order_length():
return None
- pattern_index = self._controller.add_pattern(generator)
+ pattern_index = self._controller.add_pattern(channel)
self._controller.set_order_entry(
- generator,
+ channel,
self._frame_index,
pattern_index,
)
return pattern_index
- def _used_generators(self, sample: Sample) -> List[GeneratorName]:
- """The channels a sample's reconstruction provides instructions for."""
- return [
- generator
- for generator in GeneratorName.items()
- if sample.reconstruction.get_generator_instructions(generator)
- ]
+ def _used_generators(self, voice: VoiceUnion) -> List[ChannelName]:
+ """The channels a voice sounds on."""
+ return list(voice_channels(voice))
- def _subcolumn_generators(self, row_index: int) -> List[GeneratorName]:
+ def _subcolumn_generators(self, row_index: int) -> List[ChannelName]:
"""Channels a sample-column transpose/volume edit writes to.
Falls back to every channel when no sample constrains the row, mirroring
- :attr:`SequencerRowViewModel.subcolumn_generators`.
+ :attr:`SequencerRowViewModel.subcolumn_channels`.
"""
- referenced = self.referenced_generators(row_index)
+ referenced = self.referenced_channels(row_index)
if not referenced:
- return GeneratorName.items()
+ return ChannelName.items()
- return [generator for generator in GeneratorName.items() if generator in referenced]
+ return [channel for channel in ChannelName.items() if channel in referenced]
- def referenced_generators(self, row_index: int) -> FrozenSet[GeneratorName]:
+ def referenced_channels(self, row_index: int) -> FrozenSet[ChannelName]:
"""The channels spanned by the samples a row names.
Reads the row from every channel's pattern, so it reports a sample's whole
span even where some of its cells stand empty. A row naming no sample
- references no channel, which is what :meth:`relevant_generators` widens to
+ references no channel, which is what :meth:`relevant_channels` widens to
every channel.
"""
- rows: Dict[GeneratorName, Optional[Row]] = {}
- for generator in GeneratorName.items():
- pattern_index = self._pattern_index_at_frame(generator)
+ rows: Dict[ChannelName, Optional[Row]] = {}
+ for channel in ChannelName.items():
+ pattern_index = self._pattern_index_at_frame(channel)
pattern = (
self._controller.project.song.pattern(
- generator,
+ channel,
pattern_index,
)
if pattern_index is not None
else None
)
- rows[generator] = pattern.rows[row_index] if pattern is not None else None
+ rows[channel] = pattern.rows[row_index] if pattern is not None else None
return self._referenced_generators_from_rows(rows)
def _referenced_generators_from_rows(
self,
- rows: Dict[GeneratorName, Optional[Row]],
- ) -> FrozenSet[GeneratorName]:
- """The channels spanned by the samples referenced on a row.
+ rows: Dict[ChannelName, Optional[Row]],
+ ) -> FrozenSet[ChannelName]:
+ """The channels spanned by the samples a row names.
- Each referenced sample contributes the channels its reconstruction covers,
- so the sample column reasons about a sample's whole channel span, including
- channels whose cells are empty.
+ A sample contributes the channels its reconstruction covers, so the sample column reasons
+ about its whole span including channels whose cells stand empty. A hand-written instrument
+ sounds on the one channel it is named in, so it contributes none and leaves the column
+ speaking for samples alone. A row naming a voice the project no longer holds contributes
+ the channel it sits on, which keeps that cell reachable while the reference stands.
"""
- relevant: Set[GeneratorName] = set()
+ relevant: Set[ChannelName] = set()
resolved: Set[str] = set()
- for row in rows.values():
+ for channel, row in rows.items():
command = row.command if row is not None else None
- if not isinstance(command, Instrument):
+ if not isinstance(command, NoteOn):
continue
- sample_id = command.sample_id
- if sample_id in resolved:
+ voice_id = command.voice_id
+ if voice_id in resolved:
continue
- resolved.add(sample_id)
- sample = self._controller.project.samples.get(sample_id)
- if sample is None:
- relevant.add(command.generator_name)
- else:
- relevant.update(self._used_generators(sample))
+ resolved.add(voice_id)
+ voice = self._controller.project.voices.get(voice_id)
+ if voice is None:
+ relevant.add(channel)
+ elif voice_kind(voice).places_across_channels:
+ relevant.update(self._used_generators(voice))
return frozenset(relevant)
def _build_row(
self,
index: int,
- patterns: Dict[GeneratorName, Pattern],
+ patterns: Dict[ChannelName, Pattern],
+ carried: Dict[ChannelName, Optional[VoiceUnion]],
) -> SequencerRowViewModel:
- rows: Dict[GeneratorName, Optional[Row]] = {}
- cells: Dict[GeneratorName, SequencerCellViewModel] = {}
- for generator in GeneratorName.items():
- pattern = patterns.get(generator)
+ """One grid line, with each channel read in the terms of the voice it is carrying.
+
+ ``carried`` walks down the frame with the rows, so a line bending a note it did not start
+ still reads in that voice's terms.
+ """
+ rows: Dict[ChannelName, Optional[Row]] = {}
+ cells: Dict[ChannelName, SequencerCellViewModel] = {}
+ for channel in ChannelName.items():
+ pattern = patterns.get(channel)
if pattern is not None and index < pattern.length:
row = pattern.rows[index]
- rows[generator] = row
- cells[generator] = self._build_cell(row)
+ rows[channel] = row
+ carried[channel] = self._carried_voice(row, carried[channel])
+ cells[channel] = self._build_cell(row, channel, carried[channel])
else:
- rows[generator] = None
- cells[generator] = _EMPTY_CELL
+ rows[channel] = None
+ cells[channel] = _EMPTY_CELL
return SequencerRowViewModel(
index=index,
cells=cells,
- relevant_generators=self._referenced_generators_from_rows(rows),
+ sample_channels=self._referenced_generators_from_rows(rows),
)
- def _build_cell(self, row: Row) -> SequencerCellViewModel:
+ def _carried_voice(
+ self,
+ row: Row,
+ carried: Optional[VoiceUnion],
+ ) -> Optional[VoiceUnion]:
+ """The voice a channel carries once it has reached ``row``.
+
+ A note column names the voice from that line on, a note-off leaves the channel carrying
+ none, and a line naming neither plays on with whatever it already had.
+ """
+ match row.command:
+ case NoteOn() as note_on:
+ return self._controller.project.voices.get(note_on.voice_id)
+ case NoteOff():
+ return None
+ case None:
+ return carried
+
+ def _build_cell(
+ self,
+ row: Row,
+ channel: ChannelName,
+ voice: Optional[VoiceUnion],
+ ) -> SequencerCellViewModel:
+ """One cell's three readings, the pitch stated in the terms its voice is written in.
+
+ A sample was converted at a pitch of its own, so its rows read as steps from it; an instrument
+ was written against a root the reader chose, so its rows read as the notes they sound.
+ """
return SequencerCellViewModel(
- instrument=display_command(
- self._controller.project.samples,
+ voice=display_command(
+ self._controller.project.voices,
row.command,
),
- transpose=display_transpose(row.transpose),
+ transpose=self._display_pitch(row.transpose, channel, voice),
volume=display_volume(row.volume),
+ kind=self._named_kind(row.command),
)
+ def _named_kind(self, command: Optional[NoteCommand]) -> Optional[VoiceKind]:
+ """The kind of the voice a row names, absent where it names none the project holds.
+
+ The cell states the voice it starts, so the kind is read from that command rather than from
+ whatever the channel carries into the row: a line that only bends a note names nothing and
+ takes the kind of nothing.
+ """
+ match command:
+ case NoteOn() as note_on:
+ voice = self._controller.project.voices.get(note_on.voice_id)
+ return voice_kind(voice) if voice is not None else None
+ case _:
+ return None
+
+ @staticmethod
+ def _display_pitch(
+ transpose: Optional[int],
+ channel: ChannelName,
+ voice: Optional[VoiceUnion],
+ ) -> str:
+ match voice:
+ case Instrument():
+ return display_note(
+ transpose,
+ channel_name=channel,
+ reference=voice_reference(voice, channel),
+ )
+ case _:
+ return display_transpose(transpose)
+
def _clamp_frame(self, frame_count: int) -> int:
if frame_count == 0:
return 0
diff --git a/src/sampletones_application/logic/sequencer/tracker/writer.py b/src/sampletones_application/logic/sequencer/tracker/writer.py
index 22a984380..80ca31837 100644
--- a/src/sampletones_application/logic/sequencer/tracker/writer.py
+++ b/src/sampletones_application/logic/sequencer/tracker/writer.py
@@ -11,8 +11,8 @@
slot_from_flat,
)
from sampletones_application.view_model.sequencer.subcolumn import SubColumn
-from sampletones_core.constants.enums import GeneratorName
-from sampletones_core.project.instruments.note_off import NoteOff
+from sampletones_core.constants.enums import ChannelName
+from sampletones_core.project.voices.note_off import NoteOff
from .block import BlockKey, BlockNote, TrackerBlock
from .tracker import SequencerTrackerLogic
@@ -38,7 +38,7 @@ def write(self, block: TrackerBlock, cell: TrackerCell) -> None:
sample column decides the whole row, so the transposes and volumes sharing that row land
on top of the channels it settled.
"""
- base = column_slot_base(cell.generator)
+ base = column_slot_base(cell.channel)
self._write_pass(block.notes, cell, base, self._write_note)
self._write_pass(block.transposes, cell, base, self._write_transpose)
self._write_pass(block.volumes, cell, base, self._write_volume)
@@ -49,7 +49,7 @@ def clear(self, region: TrackerRegion) -> None:
for slot in region.slots:
self._tracker.clear_cell_subcolumn(
row_index,
- slot.generator,
+ slot.channel,
slot.subcolumn,
)
@@ -58,7 +58,7 @@ def _write_pass(
values: Dict[BlockKey, ValueT],
cell: TrackerCell,
base: int,
- write: Callable[[int, Optional[GeneratorName], ValueT], None],
+ write: Callable[[int, Optional[ChannelName], ValueT], None],
) -> None:
"""Writes one kind of subcolumn across the block, dropping what falls outside the grid.
@@ -74,67 +74,68 @@ def _write_pass(
if row_index >= row_count or slot_index >= SLOT_COUNT:
continue
- write(row_index, slot_from_flat(slot_index).generator, value)
+ write(row_index, slot_from_flat(slot_index).channel, value)
def _write_note(
self,
row_index: int,
- generator: Optional[GeneratorName],
+ channel: Optional[ChannelName],
note: Optional[BlockNote],
) -> None:
- """Writes the note a cell carries: a sample by id, a cut, or the emptiness of neither.
+ """Writes the note a cell carries: a voice by id, a cut, or the emptiness of neither.
- A sample the project no longer holds leaves the cell as it stands, so a block outliving
+ A voice the project no longer holds leaves the cell as it stands, so a block outliving
the project it was read from writes the notes that still name something and passes over
- the rest.
+ the rest. The column the note lands in decides whether it takes that voice, which
+ :meth:`SequencerTrackerLogic.place_note` answers for every route alike.
"""
match note:
case NoteOff():
- self._tracker.cut_note(row_index, generator)
- case str() as sample_id:
- if self._tracker.holds_sample(sample_id):
- self._tracker.place_note(row_index, generator, sample_id)
+ self._tracker.cut_note(row_index, channel)
+ case str() as voice_id:
+ if self._tracker.holds_voice(voice_id):
+ self._tracker.place_note(row_index, channel, voice_id)
case None:
self._tracker.clear_cell_subcolumn(
row_index,
- generator,
- SubColumn.INSTRUMENT,
+ channel,
+ SubColumn.VOICE,
)
def _write_transpose(
self,
row_index: int,
- generator: Optional[GeneratorName],
+ channel: Optional[ChannelName],
transpose: Optional[int],
) -> None:
if transpose is None:
self._tracker.clear_cell_subcolumn(
row_index,
- generator,
+ channel,
SubColumn.TRANSPOSE,
)
else:
self._tracker.set_cell_subcolumn(
row_index,
- generator,
+ channel,
transpose=transpose,
)
def _write_volume(
self,
row_index: int,
- generator: Optional[GeneratorName],
+ channel: Optional[ChannelName],
volume: Optional[int],
) -> None:
if volume is None:
self._tracker.clear_cell_subcolumn(
row_index,
- generator,
+ channel,
SubColumn.VOLUME,
)
else:
self._tracker.set_cell_subcolumn(
row_index,
- generator,
+ channel,
volume=volume,
)
diff --git a/src/sampletones_application/logic/sequencer/voices.py b/src/sampletones_application/logic/sequencer/voices.py
new file mode 100644
index 000000000..f00488f85
--- /dev/null
+++ b/src/sampletones_application/logic/sequencer/voices.py
@@ -0,0 +1,339 @@
+from pathlib import Path
+from typing import Callable, Final, Optional, Tuple
+
+import numpy as np
+
+from sampletones_application.config.managers.session import SessionManager
+from sampletones_application.constants.instruments import AUDITION_TICKS
+from sampletones_application.layout.behavior.scheduling.scheduling import (
+ SchedulingBehavior,
+)
+from sampletones_application.logic.project.controller import ProjectController
+from sampletones_application.logic.shared.playback_priority import PlaybackPriority
+from sampletones_application.utils.callbacks.queue import CallbackQueue
+from sampletones_application.view_model.sequencer.kind import voice_kind
+from sampletones_application.view_model.sequencer.voices import (
+ SequencerVoicesViewModel,
+ VoiceEntryViewModel,
+ VoiceKind,
+)
+from sampletones_application.view_model.shared.footprint import VoiceFootprintViewModel
+from sampletones_core.audio import AudioDeviceManager
+from sampletones_core.configs import Config
+from sampletones_core.constants.enums import ChannelName
+from sampletones_core.exporters.slices import VoiceSlice, sample_slices
+from sampletones_core.formats.famitracker.footprint import (
+ features_footprint,
+ reconstruction_footprints,
+)
+from sampletones_core.formats.famitracker.instrument import read_fti
+from sampletones_core.formats.famitracker.voice import (
+ ImportedVoice,
+ instrument_to_voice,
+)
+from sampletones_core.performance.audition import audition_audio, audition_ticks
+from sampletones_core.project.voices.creation import (
+ instrument_from_features,
+ new_instrument,
+)
+from sampletones_core.project.voices.instrument import Instrument
+from sampletones_core.project.voices.sample import Sample
+from sampletones_core.reconstructions import Reconstruction
+from sampletones_core.utils.display import display_voice
+from sampletones_shared.exceptions import PlaybackError
+from sampletones_shared.logger import logger
+from sampletones_shared.types.callback import StringCallback
+from sampletones_shared.utils.callbacks import CallbackMixin
+
+PREVIEW_CHANNEL: Final[ChannelName] = ChannelName.PULSE1
+
+
+class SequencerVoicesLogic(CallbackMixin):
+ """Drives the voices panel: lists the pool, edits it, and previews what it holds.
+
+ Every pool edit goes through the controller so the project stays the single source of truth.
+ ``on_edit_voice_requested`` hands a voice id to the application, which opens that voice in the
+ Reconstructions tab — a recording as the reconstruction behind it, a hand-written one as its
+ envelopes.
+
+ Previewing mirrors the reconstruction browser: a single click schedules a debounced autoplay
+ that fires only when the session's autoplay flag is on, and a double-click (edit) cancels the
+ pending preview before it plays.
+ """
+
+ def __init__(
+ self,
+ project_controller: ProjectController,
+ session_manager: SessionManager,
+ audio_device_manager: AudioDeviceManager,
+ *,
+ scheduling: SchedulingBehavior,
+ ) -> None:
+ self._controller = project_controller
+ self._session_manager = session_manager
+ self._audio_device_manager = audio_device_manager
+ self._scheduling = scheduling
+ self._pending_autoplay_voice: Optional[str] = None
+
+ self.on_voices_changed: Optional[Callable[[SequencerVoicesViewModel], None]] = None
+ self.on_edit_voice_requested: Optional[StringCallback] = None
+ self.on_autoplay_error: Optional[Callable[[Exception], None]] = None
+
+ def build_voices(self) -> SequencerVoicesViewModel:
+ entries = tuple(
+ VoiceEntryViewModel(
+ voice_id=voice.id,
+ name=voice.name,
+ kind=voice_kind(voice),
+ )
+ for voice in self._controller.project.voices
+ )
+ return SequencerVoicesViewModel(voices=entries)
+
+ def push_voices(self) -> None:
+ self.call(self.on_voices_changed, self.build_voices())
+
+ def add_sample(self, reconstruction: Reconstruction, name: str) -> Sample:
+ return self._controller.add_sample(reconstruction, name)
+
+ def add_new_instrument(self, name: str) -> Instrument:
+ """Writes a fresh instrument into the pool, sustaining until its envelopes are edited."""
+ return self.add_instrument(new_instrument(name))
+
+ def add_instrument(self, instrument: Instrument) -> Instrument:
+ """Takes a whole instrument voice into the pool, whichever route made it."""
+ return self._controller.add_instrument(instrument)
+
+ def read_instrument(self, filepath: Path) -> ImportedVoice:
+ """Reads a FamiTracker instrument file as a voice, leaving the pool as it stands.
+
+ The file states the name the voice takes, and a file naming nothing leaves the voice
+ named after the file itself, so the list states where every voice came from.
+
+ Args:
+ filepath: The ``.fti`` file the voice is read from.
+
+ Returns:
+ ImportedVoice: The voice the file describes, beside what the file stated past it.
+
+ Raises:
+ FileNotFoundError: If no file stands at ``filepath``.
+ LoadInstrumentError: If the file departs from the instrument layout.
+ """
+ imported = instrument_to_voice(read_fti(filepath))
+ if not imported.voice.name:
+ imported.voice.name = filepath.stem
+
+ return imported
+
+ def instrument_channels(self, voice_id: str) -> Tuple[ChannelName, ...]:
+ """The channels of one voice a new instrument can be written from.
+
+ A recording's channel carries frames of its own, so each of them makes a voice of
+ envelopes the reader edits directly. A voice written by hand already is that, so it offers
+ none and the menu says so.
+
+ Args:
+ voice_id: The voice a new instrument would be taken from.
+
+ Returns:
+ Tuple[ChannelName, ...]: The channels it offers, in channel order.
+ """
+ return tuple(voice_slice.channel for voice_slice in self._channel_slices(voice_id))
+
+ def instrument_from_channel(
+ self,
+ voice_id: str,
+ channel_name: ChannelName,
+ ) -> Optional[Instrument]:
+ """Writes what one channel of a voice plays into an instrument, leaving the pool as it stands.
+
+ The new voice carries the channel's envelopes and the reference they were measured
+ against, and it is named after the channel it came from, so the list says where it came
+ from the way an exported slice does.
+
+ Args:
+ voice_id: The voice the channel belongs to.
+ channel_name: The channel whose envelopes the instrument takes.
+
+ Returns:
+ Optional[Instrument]: The voice those envelopes describe, or ``None`` where the pool
+ holds no such voice or it plays nothing on that channel.
+ """
+ voice_slice = next(
+ (candidate for candidate in self._channel_slices(voice_id) if candidate.channel is channel_name),
+ None,
+ )
+ if voice_slice is None:
+ return None
+
+ return instrument_from_features(
+ voice_slice.instrument_name,
+ voice_slice.features,
+ channel_name,
+ )
+
+ def _channel_slices(self, voice_id: str) -> Tuple[VoiceSlice, ...]:
+ """What each channel of a recording plays, which is what an instrument is written from."""
+ match self._controller.project.voices.get(voice_id):
+ case Sample() as sample:
+ return tuple(sample_slices(sample))
+ case _:
+ return ()
+
+ def rename_voice(self, voice_id: str, name: str) -> None:
+ self._controller.rename_voice(voice_id, name)
+
+ def is_voice_used(self, voice_id: str) -> bool:
+ return self._controller.is_voice_used(voice_id)
+
+ def build_voice_footprint(
+ self,
+ voice_id: str,
+ ) -> Optional[VoiceFootprintViewModel]:
+ """Measures one voice's instruments as the module export writes them.
+
+ A sample yields a figure per channel its reconstruction covers; an instrument yields one,
+ since every channel reaches the same envelopes. Measuring a single voice on demand keeps a
+ pool edit clear of an export it was not asked for.
+
+ Args:
+ voice_id: The voice to measure.
+
+ Returns:
+ Optional[VoiceFootprintViewModel]: The voice's byte figures, or ``None`` while the
+ pool holds no such voice.
+ """
+ match self._controller.project.voices.get(voice_id):
+ case Sample() as sample:
+ return VoiceFootprintViewModel.from_footprints(reconstruction_footprints(sample.reconstruction))
+ case Instrument() as instrument:
+ return VoiceFootprintViewModel.from_instrument(features_footprint(instrument.instrument_features()))
+ case _:
+ return None
+
+ def voice_name(self, voice_id: str) -> str:
+ return self._controller.project.voices[voice_id].name
+
+ def voice_position(self, voice_id: str) -> str:
+ """Returns the voice's hex list position, matching how the tracker labels it."""
+ return display_voice(
+ voices=self._controller.project.voices,
+ voice_id=voice_id,
+ )
+
+ def voice_kind(self, voice_id: str) -> Optional[VoiceKind]:
+ """Which of the two kinds a voice in the pool is, telling a recording from a written one.
+
+ Args:
+ voice_id: The voice being asked about.
+
+ Returns:
+ Optional[VoiceKind]: The kind the pool holds it as, or ``None`` while the pool holds
+ no such voice.
+ """
+ voice = self._controller.project.voices.get(voice_id)
+ if voice is None:
+ return None
+
+ return voice_kind(voice)
+
+ def remove_voice(self, voice_id: str) -> None:
+ self._controller.remove_voice(voice_id)
+
+ def move_voice(self, voice_id: str, to_index: int) -> None:
+ self._controller.move_voice(voice_id, to_index)
+
+ def duplicate_voice(self, voice_id: str) -> None:
+ self._controller.duplicate_voice(voice_id)
+
+ def request_edit(self, voice_id: str) -> None:
+ self.cancel_autoplay()
+ self.call(self.on_edit_voice_requested, voice_id)
+
+ def play_voice(self, voice_id: str) -> None:
+ """Plays a voice on demand, regardless of the autoplay setting.
+
+ Explicit playback is intentional, so it uses ``NORMAL`` priority and thereby
+ preempts the sequencer song / reconstruction players.
+ """
+ self._play_voice(voice_id, priority=PlaybackPriority.NORMAL)
+
+ def request_autoplay(self, voice_id: str) -> None:
+ """Schedules a debounced preview that a following double-click can cancel."""
+ self._pending_autoplay_voice = voice_id
+ CallbackQueue.add(
+ self._execute_autoplay,
+ priority=self._scheduling.priorities.schedule,
+ delay=self._scheduling.delays.schedule,
+ )
+
+ def cancel_autoplay(self) -> None:
+ self._pending_autoplay_voice = None
+
+ def _execute_autoplay(self) -> None:
+ if self._pending_autoplay_voice is None:
+ return
+
+ voice_id = self._pending_autoplay_voice
+ self._pending_autoplay_voice = None
+ if self._session_manager.autoplay:
+ self._play_voice(voice_id, priority=PlaybackPriority.PREVIEW)
+
+ def _preview_audio(self, voice_id: str) -> Optional[np.ndarray]:
+ """The audio a preview sounds: a sample's approximation, or an instrument on the pulse.
+
+ The pulse channel offers every dimension an instrument writes, so sounding the preview
+ there sounds the whole instrument rather than the part another channel would read. The
+ pool states a voice rather than a note, so it sounds at the pitch the instrument itself is
+ measured against.
+
+ Args:
+ voice_id: The voice to preview.
+
+ Returns:
+ Optional[np.ndarray]: The waveform to play, or ``None`` where the voice sounds nothing.
+ """
+ match self._controller.project.voices.get(voice_id):
+ case Sample() as sample:
+ return sample.reconstruction.approximation
+ case Instrument() as instrument:
+ return audition_audio(
+ instrument,
+ PREVIEW_CHANNEL,
+ self._preview_config(),
+ pitch=instrument.reference(PREVIEW_CHANNEL),
+ ticks=audition_ticks(instrument, cap=AUDITION_TICKS),
+ )
+ case _:
+ return None
+
+ def _preview_config(self) -> Config:
+ settings = self._controller.project.settings
+ return Config().with_library(
+ nes_frequency=settings.nes_frequency,
+ sample_rate=settings.sample_rate,
+ )
+
+ def _play_voice(
+ self,
+ voice_id: str,
+ *,
+ priority: PlaybackPriority,
+ ) -> None:
+ audio = self._preview_audio(voice_id)
+ if audio is None:
+ return
+
+ try:
+ self._audio_device_manager.play(
+ audio,
+ update=False,
+ priority=priority,
+ )
+ except (PlaybackError, ValueError) as exception:
+ logger.error_with_traceback(
+ exception,
+ f"Failed to preview voice: {voice_id}",
+ )
+ self.call(self.on_autoplay_error, exception)
diff --git a/src/sampletones_application/logic/shared/project_source.py b/src/sampletones_application/logic/shared/project_source.py
index 208546746..a2ca8c7be 100644
--- a/src/sampletones_application/logic/shared/project_source.py
+++ b/src/sampletones_application/logic/shared/project_source.py
@@ -3,6 +3,7 @@
from typing import Dict, Protocol, Self
from sampletones_core.project import Project
+from sampletones_core.project.voices.voice import samples
def snapshot_project(project: Project) -> Project:
@@ -16,7 +17,7 @@ def snapshot_project(project: Project) -> Project:
snapshot.
"""
shared_reconstructions: Dict[int, object] = {
- id(sample.reconstruction): sample.reconstruction for sample in project.samples
+ id(sample.reconstruction): sample.reconstruction for sample in samples(project.voices)
}
return copy.deepcopy(project, shared_reconstructions)
diff --git a/src/sampletones_application/parameters/geometry.py b/src/sampletones_application/parameters/geometry.py
index c6826bd35..140abab02 100644
--- a/src/sampletones_application/parameters/geometry.py
+++ b/src/sampletones_application/parameters/geometry.py
@@ -10,7 +10,7 @@ class TabGeometry:
"""The geometry every tab coordinator lays its columns out on.
These six values are identical across all four tabs: the uniform side column's
- size, the responsive baseline and centre share that drive its width as the
+ size, the responsive baseline and center share that drive its width as the
viewport grows, the rail it docks to when collapsed, and the gap between panels.
They are flattened to scalars because each feeds a pure-int sink
(``expanded_side_width``, ``ColumnSpec``, raw ``dpg.configure_item``) that blends
diff --git a/src/sampletones_application/parameters/instructions.py b/src/sampletones_application/parameters/instructions.py
index 10c592ead..b2c932c5b 100644
--- a/src/sampletones_application/parameters/instructions.py
+++ b/src/sampletones_application/parameters/instructions.py
@@ -4,6 +4,7 @@
from sampletones_application.layout.behavior.scheduling.scheduling import SchedulingBehavior
from sampletones_application.layout.config import LayoutConfig
+from sampletones_application.layout.general.colors.channel import ChannelColors
from sampletones_application.layout.general.colors.table import TableColors
from sampletones_application.layout.general.tables import TablesLayout
from sampletones_application.layout.graphs import GraphsLayout
@@ -34,6 +35,7 @@ class InstructionsTabParameters:
graphs: GraphsLayout
pitch_stepper_style: PitchStepperStyle
table_colors: TableColors
+ channel_colors: ChannelColors
tables: TablesLayout
tree_colors: TreeColors
scheduling: SchedulingBehavior
@@ -52,6 +54,7 @@ def from_config(cls, config: LayoutConfig) -> InstructionsTabParameters:
graphs=config.graphs,
pitch_stepper_style=PitchStepperStyle.from_general(general),
table_colors=general.colors.tables,
+ channel_colors=general.colors.channels,
tables=general.tables,
tree_colors=TreeColors.create(
general.colors,
diff --git a/src/sampletones_application/parameters/main.py b/src/sampletones_application/parameters/main.py
index ec602b45e..d7e9fa948 100644
--- a/src/sampletones_application/parameters/main.py
+++ b/src/sampletones_application/parameters/main.py
@@ -6,6 +6,7 @@
from sampletones_application.layout.config import LayoutConfig
from sampletones_application.layout.general.colors.path import PathColors
from sampletones_application.layout.general.inputs import InputsLayout
+from sampletones_application.layout.general.stems import StemsListLayout
from sampletones_application.layout.tabs.main import MainLayout
from sampletones_application.parameters.geometry import TabGeometry
from sampletones_application.ui.elements.tree.colors import TreeColors
@@ -24,6 +25,7 @@ class MainTabParameters:
config_height: int
main: MainLayout
inputs: InputsLayout
+ stems: StemsListLayout
path_colors: PathColors
tree_colors: TreeColors
scheduling: SchedulingBehavior
@@ -36,6 +38,7 @@ def from_config(cls, config: LayoutConfig) -> MainTabParameters:
config_height=config.tabs.main.config.height,
main=config.tabs.main,
inputs=general.inputs,
+ stems=general.stems,
path_colors=general.colors.paths,
tree_colors=TreeColors.create(
general.colors,
diff --git a/src/sampletones_application/parameters/reconstruction.py b/src/sampletones_application/parameters/reconstruction.py
index 42adf0170..573d844a1 100644
--- a/src/sampletones_application/parameters/reconstruction.py
+++ b/src/sampletones_application/parameters/reconstruction.py
@@ -4,8 +4,10 @@
from sampletones_application.layout.behavior.scheduling.scheduling import SchedulingBehavior
from sampletones_application.layout.config import LayoutConfig
+from sampletones_application.layout.general.colors.channel import ChannelColors
from sampletones_application.layout.general.colors.feature import FeatureColors
from sampletones_application.layout.general.colors.path import PathColors
+from sampletones_application.layout.general.stems import StemsListLayout
from sampletones_application.layout.graphs import GraphsLayout
from sampletones_application.parameters.geometry import TabGeometry
from sampletones_application.ui.elements.pitch_stepper import PitchStepperStyle
@@ -29,9 +31,11 @@ class ReconstructionTabParameters:
pitch_stepper_style: PitchStepperStyle
copy_width: int
feature_colors: FeatureColors
+ channel_colors: ChannelColors
path_colors: PathColors
path_status_color: BaseColor
tree_colors: TreeColors
+ stems: StemsListLayout
scheduling: SchedulingBehavior
@classmethod
@@ -45,11 +49,13 @@ def from_config(cls, config: LayoutConfig) -> ReconstructionTabParameters:
pitch_stepper_style=PitchStepperStyle.from_general(general),
copy_width=general.buttons.copy_width,
feature_colors=general.colors.features,
+ channel_colors=general.colors.channels,
path_colors=general.colors.paths,
path_status_color=general.colors.text.disabled,
tree_colors=TreeColors.create(
general.colors,
accent=general.colors.headers.reconstruction,
),
+ stems=general.stems,
scheduling=config.behavior.scheduling,
)
diff --git a/src/sampletones_application/parameters/sequencer.py b/src/sampletones_application/parameters/sequencer.py
index d071ee3c6..cfe762eeb 100644
--- a/src/sampletones_application/parameters/sequencer.py
+++ b/src/sampletones_application/parameters/sequencer.py
@@ -4,6 +4,7 @@
from sampletones_application.layout.behavior.scheduling.scheduling import SchedulingBehavior
from sampletones_application.layout.config import LayoutConfig
+from sampletones_application.layout.general.colors.channel import ChannelColors
from sampletones_application.layout.general.colors.feature import FeatureColors
from sampletones_application.layout.general.inputs import InputsLayout
from sampletones_application.layout.general.plus_minus_buttons import PlusMinusButtonsLayout
@@ -33,6 +34,7 @@ class SequencerTabParameters:
inputs: InputsLayout
plus_minus: PlusMinusButtonsLayout
feature_colors: FeatureColors
+ channel_colors: ChannelColors
tree_colors: TreeColors
muted_color: BaseColor
scheduling: SchedulingBehavior
@@ -50,6 +52,7 @@ def from_config(cls, config: LayoutConfig) -> SequencerTabParameters:
inputs=general.inputs,
plus_minus=general.plus_minus_buttons,
feature_colors=general.colors.features,
+ channel_colors=general.colors.channels,
tree_colors=TreeColors.create(
general.colors,
accent=general.colors.headers.reconstruction,
diff --git a/src/sampletones_application/services/__init__.py b/src/sampletones_application/services/__init__.py
index 59f0dcca7..0eebac09c 100644
--- a/src/sampletones_application/services/__init__.py
+++ b/src/sampletones_application/services/__init__.py
@@ -1,23 +1,27 @@
from sampletones_application.services.base import ServiceBase
-from sampletones_application.services.conversion import ConversionService
+from sampletones_application.services.conversion.result import (
+ ConversionItem,
+ ConversionResult,
+ ReconstructionStep,
+)
+from sampletones_application.services.conversion.service import ConversionService
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.services.regeneration import (
+from sampletones_application.services.regeneration.result import (
RegeneratedInstrument,
RegenerationResult,
- RegenerationService,
)
+from sampletones_application.services.regeneration.service import RegenerationService
from sampletones_application.services.render import (
RenderResult,
RenderStage,
SongRenderService,
)
from sampletones_application.services.result import (
- ConversionResult,
- ServiceCancelled,
+ ServiceCanceled,
ServiceError,
ServiceIntermediate,
ServiceProgress,
@@ -28,6 +32,7 @@
from sampletones_application.services.synthesis import RowSynthesizerProtocol
__all__ = [
+ "ConversionItem",
"ConversionResult",
"ConversionService",
"ExportError",
@@ -35,6 +40,7 @@
"ExportResult",
"ExportService",
"ExportSuccess",
+ "ReconstructionStep",
"RegeneratedInstrument",
"RegenerationResult",
"RegenerationService",
@@ -45,7 +51,7 @@
"RowSynthesizerProtocol",
"SampleRetuneService",
"ServiceBase",
- "ServiceCancelled",
+ "ServiceCanceled",
"ServiceError",
"ServiceIntermediate",
"ServiceProgress",
diff --git a/src/sampletones_application/services/conversion/__init__.py b/src/sampletones_application/services/conversion/__init__.py
new file mode 100644
index 000000000..b89ec3cfe
--- /dev/null
+++ b/src/sampletones_application/services/conversion/__init__.py
@@ -0,0 +1,9 @@
+from .result import ConversionItem, ConversionResult, ReconstructionStep
+from .service import ConversionService
+
+__all__ = [
+ "ConversionItem",
+ "ConversionResult",
+ "ConversionService",
+ "ReconstructionStep",
+]
diff --git a/src/sampletones_application/services/conversion/result.py b/src/sampletones_application/services/conversion/result.py
new file mode 100644
index 000000000..41caa70e4
--- /dev/null
+++ b/src/sampletones_application/services/conversion/result.py
@@ -0,0 +1,51 @@
+from dataclasses import dataclass
+from pathlib import Path
+from typing import Optional, Tuple, Union
+
+from sampletones_application.services.result import (
+ ServiceCanceled,
+ ServiceError,
+ ServiceIntermediate,
+ ServiceProgress,
+ ServiceStarted,
+ ServiceSuccess,
+)
+from sampletones_core.parallelization import TaskProgress
+from sampletones_core.reconstructions.stage import ReconstructionStage
+
+
+@dataclass(frozen=True)
+class ReconstructionStep:
+ """What the reconstruction under way is doing, in the unit that work counts in.
+
+ A conversion counts the files it writes, which for one recording — or one set of stems, since
+ those are one reconstruction too — counts to one. The stage and its counts are the whole of
+ what a reader watching a single conversion has to go on.
+ """
+
+ stage: ReconstructionStage
+ completed: int
+ total: int
+
+
+@dataclass(frozen=True)
+class ConversionItem:
+ """The reconstruction a conversion is building, and what it is doing to build it.
+
+ A run knows which recording it is reading from the moment it starts, and hears what that
+ reconstruction is doing once the reconstruction has something to say, so the step arrives on
+ an item that already names its source.
+ """
+
+ source: Path
+ step: Optional[ReconstructionStep] = None
+
+
+ConversionResult = Union[
+ ServiceStarted,
+ ServiceProgress[ConversionItem],
+ ServiceIntermediate[TaskProgress],
+ ServiceSuccess[Tuple[Path, ...]],
+ ServiceError,
+ ServiceCanceled,
+]
diff --git a/src/sampletones_application/services/conversion.py b/src/sampletones_application/services/conversion/service.py
similarity index 57%
rename from src/sampletones_application/services/conversion.py
rename to src/sampletones_application/services/conversion/service.py
index 5b07c9ed6..9f34f648a 100644
--- a/src/sampletones_application/services/conversion.py
+++ b/src/sampletones_application/services/conversion/service.py
@@ -1,10 +1,14 @@
from pathlib import Path
-from typing import Optional
+from typing import Optional, Tuple
from sampletones_application.services.base import ServiceBase
-from sampletones_application.services.result import (
+from sampletones_application.services.conversion.result import (
+ ConversionItem,
ConversionResult,
- ServiceCancelled,
+ ReconstructionStep,
+)
+from sampletones_application.services.result import (
+ ServiceCanceled,
ServiceError,
ServiceIntermediate,
ServiceProgress,
@@ -13,7 +17,9 @@
)
from sampletones_core.configs import Config
from sampletones_core.parallelization import ETAEstimator, TaskProgress, TaskStatus
-from sampletones_core.reconstructions.converter import ReconstructionConverter
+from sampletones_core.parallelization.task import TaskStep
+from sampletones_core.reconstructions.converter import ConversionPlan, ReconstructionConverter
+from sampletones_core.reconstructions.stage import ReconstructionStage
from sampletones_shared.logger import logger
from sampletones_shared.utils.system.paths import to_path
@@ -22,7 +28,7 @@ class ConversionService(ServiceBase[ConversionResult]):
"""
Translates raw ``ReconstructionConverter`` callbacks into a uniform result stream.
- This normalises the impedance mismatch between the core converter's ad-hoc
+ This normalizes the impedance mismatch between the core converter's ad-hoc
callback interface and the subscriber model used throughout the application.
Library-generation progress is forwarded through the same stream so the
converter panel has a single unified view.
@@ -33,23 +39,18 @@ def __init__(self, priority: int = 0) -> None:
self._converter: Optional[ReconstructionConverter] = None
self._eta_estimator: Optional[ETAEstimator] = None
- def start(self, config: Config, input_path: Path) -> None:
+ def start(self, config: Config, plan: ConversionPlan) -> None:
if self._converter is not None and self._converter.is_running():
logger.warning("Conversion is already in progress")
return
- is_file = input_path.is_file()
- self._converter = ReconstructionConverter(
- config=config,
- input_path=input_path,
- is_file=is_file,
- )
+ self._converter = ReconstructionConverter(config=config, plan=plan)
self._converter.set_callbacks(
on_start=self._on_start,
on_progress=self._on_progress,
on_completed=self._on_completed,
on_error=self._on_error,
- on_cancelled=self._on_cancelled,
+ on_canceled=self._on_canceled,
)
self._converter.start()
@@ -91,32 +92,67 @@ def _on_progress(
task_status: TaskStatus,
task_progress: TaskProgress,
) -> None:
- current_item: Optional[Path] = None
- if task_progress.current_item is not None:
- current_item = to_path(task_progress.current_item)
-
match task_status:
case TaskStatus.RUNNING | TaskStatus.CANCELLING:
- eta_seconds = self._eta_estimator.update(task_progress.completed) if self._eta_estimator else None
self._emit(
ServiceProgress(
completed=task_progress.completed,
total=task_progress.total,
- current_item=current_item,
- eta_seconds=eta_seconds,
+ current_item=self._item(task_progress),
+ eta_seconds=self._estimate(task_progress),
+ partial=task_progress.partial,
)
)
case _:
pass
- def _on_completed(self, output_path: Path) -> None:
- self._emit(ServiceSuccess(value=output_path))
+ def _estimate(self, task_progress: TaskProgress) -> Optional[float]:
+ """How long the run has left, read from the whole of what it has covered.
+
+ The run's own reading counts the reconstruction under way, so an estimate taken from it
+ moves while a single conversion runs rather than waiting for the file to be written.
+ """
+ if self._eta_estimator is None:
+ return None
+
+ return self._eta_estimator.update(task_progress.completed + task_progress.partial)
+
+ @classmethod
+ def _item(cls, task_progress: TaskProgress) -> Optional[ConversionItem]:
+ """The reconstruction the run is building, where it has a recording to name.
+
+ A run works on as many reconstructions as it has workers and names the one it has been at
+ longest, whose step it carries; the reading a bar draws counts them all.
+ """
+ if task_progress.current_item is None:
+ return None
+
+ return ConversionItem(
+ source=to_path(task_progress.current_item),
+ step=cls._step(task_progress),
+ )
+
+ @staticmethod
+ def _step(task_progress: TaskProgress) -> Optional[ReconstructionStep]:
+ """What that reconstruction is doing, once it has said something about itself."""
+ if not task_progress.steps:
+ return None
+
+ step: TaskStep = task_progress.steps[0]
+ return ReconstructionStep(
+ stage=ReconstructionStage(step.stage),
+ completed=step.completed,
+ total=step.total,
+ )
+
+ def _on_completed(self, written: Tuple[Path, ...]) -> None:
+ self._emit(ServiceSuccess(value=written))
def _on_error(self, exception: Exception) -> None:
self._emit(ServiceError(exception=exception))
- def _on_cancelled(self) -> None:
- self._emit(ServiceCancelled())
+ def _on_canceled(self) -> None:
+ self._emit(ServiceCanceled())
def forward_library_progress(
self,
diff --git a/src/sampletones_application/services/export/error.py b/src/sampletones_application/services/export/error.py
index a7405674d..bc44bdb5f 100644
--- a/src/sampletones_application/services/export/error.py
+++ b/src/sampletones_application/services/export/error.py
@@ -2,7 +2,7 @@
from typing import Optional
from sampletones_application.services.export.kind import ExportKind
-from sampletones_core.trackers.format import TrackerFormat
+from sampletones_core.exports.format import ExportFormat
@dataclass(frozen=True, eq=False)
@@ -11,10 +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.
+ export_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]
+ export_format: Optional[ExportFormat]
exception: Exception
diff --git a/src/sampletones_application/services/export/reporter.py b/src/sampletones_application/services/export/reporter.py
new file mode 100644
index 000000000..20bcf3645
--- /dev/null
+++ b/src/sampletones_application/services/export/reporter.py
@@ -0,0 +1,55 @@
+from typing import Callable, Optional
+
+from sampletones_application.services.progress import UNMEASURED, StageProgress
+from sampletones_application.services.result import ServiceProgress
+from sampletones_core.exports.progress import ExportProgress
+from sampletones_core.exports.stage import TRAVELING_STAGES, ExportStage
+
+
+class ExportProgressReporter:
+ """Carries a format's own account of itself out to whoever is watching the export.
+
+ A run passes through stages counting in units of their own — the song's ticks, the bytes a
+ dictionary settles at, the files a batch writes — so each stage is reported against what it
+ is measured by and gets a limiter of its own the moment it is first heard from. A stage the
+ run never enters is never reported, which is what keeps a bar from being carved into equal
+ parts that mean nothing.
+
+ Compressing is the stage with no end to travel toward: it finishes when the song runs out of
+ phrases that pay for themselves, so it is measured against the room it has and states no
+ remaining time.
+ """
+
+ def __init__(
+ self,
+ emit: Callable[[ServiceProgress[ExportStage]], None],
+ withdrawn: Callable[[], bool],
+ ) -> None:
+ self._emit = emit
+ self._withdrawn = withdrawn
+ self._stage: Optional[ExportStage] = None
+ self._progress: Optional[StageProgress[ExportStage]] = None
+
+ def __call__(self, progress: ExportProgress) -> bool:
+ """Reports one stage of the run, and answers whether it goes on.
+
+ Args:
+ progress: What the format says it has reached.
+
+ Returns:
+ bool: Whether the export is still wanted.
+ """
+ self._limiter(progress).advance(progress.completed)
+ return not self._withdrawn()
+
+ def _limiter(self, progress: ExportProgress) -> StageProgress[ExportStage]:
+ if self._progress is None or self._stage != progress.stage:
+ self._stage = progress.stage
+ self._progress = StageProgress(
+ progress.stage,
+ UNMEASURED if progress.total is None else progress.total,
+ emit=self._emit,
+ estimates=progress.stage in TRAVELING_STAGES,
+ )
+
+ return self._progress
diff --git a/src/sampletones_application/services/export/result.py b/src/sampletones_application/services/export/result.py
index 5a02a1122..56e08c3a1 100644
--- a/src/sampletones_application/services/export/result.py
+++ b/src/sampletones_application/services/export/result.py
@@ -1,4 +1,24 @@
+from typing import Union
+
from sampletones_application.services.export.error import ExportError
from sampletones_application.services.export.success import ExportSuccess
+from sampletones_application.services.result import (
+ ServiceCanceled,
+ ServiceProgress,
+ ServiceStarted,
+)
+from sampletones_core.exports.stage import ExportStage
+
+ExportResult = Union[
+ ServiceStarted,
+ ServiceProgress[ExportStage],
+ ExportSuccess,
+ ExportError,
+ ServiceCanceled,
+]
-ExportResult = ExportSuccess | ExportError
+__all__ = [
+ "ExportError",
+ "ExportResult",
+ "ExportSuccess",
+]
diff --git a/src/sampletones_application/services/export/service.py b/src/sampletones_application/services/export/service.py
index e24a42d93..2ffc23402 100644
--- a/src/sampletones_application/services/export/service.py
+++ b/src/sampletones_application/services/export/service.py
@@ -1,41 +1,69 @@
+import threading
from functools import partial
from pathlib import Path
-from typing import Callable
+from typing import Callable, Final, Optional
import numpy as np
from sampletones_application.services.base import ServiceBase
from sampletones_application.services.export.error import ExportError
from sampletones_application.services.export.kind import ExportKind
+from sampletones_application.services.export.reporter import ExportProgressReporter
from sampletones_application.services.export.result import ExportResult
from sampletones_application.services.export.success import ExportSuccess
+from sampletones_application.services.result import ServiceCanceled, ServiceStarted
from sampletones_application.utils.parallelization.thread import SingleThreadExecutor
from sampletones_core.audio import write_wave
-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 (
+from sampletones_core.exports.artifact import ExportArtifact
+from sampletones_core.exports.backend import ExportBackend
+from sampletones_core.exports.format import ExportFormat
+from sampletones_core.exports.progress import ExportReporter
+from sampletones_core.exports.request import (
InstrumentExport,
ProjectExport,
SampleExport,
)
+from sampletones_shared.exceptions import OperationCanceled
from sampletones_shared.logger import logger
-NO_TRACKER_FORMAT: None = None
+NO_EXPORT_FORMAT: None = None
+WHOLE_ENVELOPE: None = None
+UNMEASURED_AT_THE_START: Final[int] = 0
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
+ The export 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.
+ subscriber report an outcome in the words of the program that reads it.
+
+ A format carrying its own player spends seconds on a song, so a run reports the stage it is
+ in as it goes and answers a cancel at the next point the format looks up. One export runs at
+ a time, and a request arriving while one is in flight is declined.
"""
def __init__(self, priority: int = 0) -> None:
super().__init__(priority)
self._executor = SingleThreadExecutor()
+ self._cancel_event = threading.Event()
+ self._running = threading.Event()
+
+ def cancel(self) -> None:
+ """Asks a running export to stop at the next point the format looks up."""
+ self._cancel_event.set()
+
+ def is_running(self) -> bool:
+ return self._running.is_set()
+
+ def shutdown(self) -> None:
+ """Winds a running export down for application exit.
+
+ The worker runs on a :class:`SingleThreadExecutor`, so the teardown that joins every
+ background worker reaches this one; asking it to stop first is what keeps that join short.
+ """
+ self._cancel_event.set()
def export_wav(
self,
@@ -43,75 +71,77 @@ def export_wav(
sample_rate: int,
audio: np.ndarray,
) -> None:
- def task() -> None:
- try:
- write_wave(filepath, sample_rate, audio)
- logger.info(f"Exported reconstruction to WAV: {logger.format_path(filepath)}")
- self._emit(
- ExportSuccess(
- kind=ExportKind.WAV,
- filepath=filepath,
- tracker_format=NO_TRACKER_FORMAT,
- truncation=None,
- )
- )
- except Exception as exception: # pylint: disable=broad-exception-caught
- logger.error_with_traceback(exception, f"Failed to export reconstruction to WAV: {filepath}")
- self._emit(
- ExportError(
- kind=ExportKind.WAV,
- tracker_format=NO_TRACKER_FORMAT,
- exception=exception,
- )
- )
+ """Writes the audio a reconstruction sounds as, straight to a file.
- self._executor.execute(task, wait=False)
+ Args:
+ filepath: The file to write.
+ sample_rate: The rate the samples were rendered at.
+ audio: The samples to write.
+ """
+ self._submit(
+ ExportKind.WAV,
+ filepath,
+ NO_EXPORT_FORMAT,
+ partial(self._written_wave, filepath, sample_rate, audio),
+ )
def export_instrument(
self,
destination: Path,
- backend: TrackerBackend,
+ backend: ExportBackend,
request: InstrumentExport,
) -> None:
self._submit(
ExportKind.INSTRUMENT,
destination,
- backend.tracker_format,
+ backend.export_format,
partial(backend.write_instrument, destination, request),
)
def export_sample(
self,
destination: Path,
- backend: TrackerBackend,
+ backend: ExportBackend,
request: SampleExport,
) -> None:
self._submit(
ExportKind.SAMPLE,
destination,
- backend.tracker_format,
+ backend.export_format,
partial(backend.write_sample, destination, request),
)
def export_project(
self,
destination: Path,
- backend: TrackerBackend,
+ backend: ExportBackend,
request: ProjectExport,
) -> None:
self._submit(
ExportKind.PROJECT,
destination,
- backend.tracker_format,
+ backend.export_format,
partial(backend.write_project, destination, request),
)
+ def _written_wave(
+ self,
+ filepath: Path,
+ sample_rate: int,
+ audio: np.ndarray,
+ _report: ExportReporter,
+ /,
+ ) -> ExportArtifact:
+ """Writes the samples straight out, which takes a moment and reports no stages."""
+ write_wave(filepath, sample_rate, audio)
+ return ExportArtifact(paths=(filepath,), truncation=WHOLE_ENVELOPE)
+
def _submit(
self,
kind: ExportKind,
destination: Path,
- tracker_format: TrackerFormat,
- write: Callable[[], ExportArtifact],
+ export_format: Optional[ExportFormat],
+ write: Callable[[ExportReporter], ExportArtifact],
) -> None:
"""Runs one backend write on the executor and reports what it produced.
@@ -122,32 +152,64 @@ def _submit(
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.
+ export_format: The format the run writes, carried through to the result, and ``None``
+ for an audio export.
+ write: Calls the backend with the reporter it says its stages through, and returns
+ what it left on disk.
"""
+ if self.is_running():
+ logger.warning(f"{self.class_name}: an export is already running; the request was declined")
+ return
- def task() -> None:
- try:
- artifact = write()
- for path in artifact.paths:
- logger.info(f"Exported {kind.value}: {logger.format_path(path)}")
-
- self._emit(
- ExportSuccess(
- 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 to: {destination}")
- self._emit(
- ExportError(
- kind=kind,
- tracker_format=tracker_format,
- exception=exception,
- )
+ self._cancel_event.clear()
+ self._running.set()
+ if not self._executor.execute(partial(self._run, kind, destination, export_format, write), wait=False):
+ self._running.clear()
+
+ def _run(
+ self,
+ kind: ExportKind,
+ destination: Path,
+ export_format: Optional[ExportFormat],
+ write: Callable[[ExportReporter], ExportArtifact],
+ ) -> None:
+ try:
+ self._emit(ServiceStarted(total=UNMEASURED_AT_THE_START))
+ self._report_written(kind, destination, export_format, write(self._reporter()))
+ except OperationCanceled:
+ logger.info(f"The export to {logger.format_path(destination)} was canceled")
+ self._emit(ServiceCanceled())
+ except Exception as exception: # pylint: disable=broad-exception-caught
+ logger.error_with_traceback(exception, f"Failed to export to: {destination}")
+ self._emit(
+ ExportError(
+ kind=kind,
+ export_format=export_format,
+ exception=exception,
)
+ )
+ finally:
+ self._running.clear()
+
+ def _report_written(
+ self,
+ kind: ExportKind,
+ destination: Path,
+ export_format: Optional[ExportFormat],
+ artifact: ExportArtifact,
+ ) -> None:
+ for path in artifact.paths:
+ logger.info(f"Exported {kind.value}: {logger.format_path(path)}")
+
+ self._emit(
+ ExportSuccess(
+ kind=kind,
+ filepath=artifact.paths[0] if artifact.paths else destination,
+ export_format=export_format,
+ truncation=artifact.truncation,
+ )
+ )
- self._executor.execute(task, wait=False)
+ def _reporter(self) -> ExportReporter:
+ """What the backend says its stages through, and asks whether the run is still wanted."""
+ return ExportProgressReporter(self._emit, self._cancel_event.is_set)
diff --git a/src/sampletones_application/services/export/success.py b/src/sampletones_application/services/export/success.py
index 502a2a948..5864f082f 100644
--- a/src/sampletones_application/services/export/success.py
+++ b/src/sampletones_application/services/export/success.py
@@ -4,7 +4,7 @@
from sampletones_application.services.export.kind import ExportKind
from sampletones_core.exporters.truncation import EnvelopeTruncation
-from sampletones_core.trackers.format import TrackerFormat
+from sampletones_core.exports.format import ExportFormat
@dataclass(frozen=True)
@@ -14,12 +14,12 @@ class ExportSuccess:
Attributes:
kind: The artefact the run produced.
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.
+ export_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
- tracker_format: Optional[TrackerFormat]
+ export_format: Optional[ExportFormat]
truncation: Optional[EnvelopeTruncation]
diff --git a/src/sampletones_application/services/progress.py b/src/sampletones_application/services/progress.py
new file mode 100644
index 000000000..fb7dd3665
--- /dev/null
+++ b/src/sampletones_application/services/progress.py
@@ -0,0 +1,72 @@
+from typing import Callable, Final, Generic, Optional, TypeVar
+
+from sampletones_application.services.result import ServiceProgress
+from sampletones_core.parallelization import ETAEstimator
+from sampletones_shared.utils.progress import ReportRate
+
+StageT = TypeVar("StageT")
+
+UNMEASURED: Final[int] = 0
+
+
+class StageProgress(Generic[StageT]):
+ """One stage of a long operation, reported at a bounded rate.
+
+ A stage may step through millions of samples or a few files, so reporting every step would
+ fill the callback queue with updates no eye resolves and no bar redraws. Emitting on a
+ fraction of what the stage is measured against holds the report rate steady whatever the work
+ is, and a stage landing on its total is always reported, so a bar arrives at its end.
+
+ What a stage counts moves by its own rules: a render's samples rise toward the song's length,
+ while a compression's bytes fall as the dictionary earns its keep. A step is therefore a
+ change of either sign.
+ """
+
+ def __init__(
+ self,
+ stage: StageT,
+ total: int,
+ *,
+ emit: Callable[[ServiceProgress[StageT]], None],
+ estimates: bool,
+ ) -> None:
+ """Holds one stage's reporting.
+
+ Args:
+ stage: The work being reported, which names the unit the counts are in.
+ total: What the stage's count is measured against, and ``UNMEASURED`` where nothing
+ bounds it.
+ emit: Carries a report to the service's subscribers.
+ estimates: Whether the count reaches ``total`` at a rate a remaining time can be read
+ from. A stage measured against a limit it is not traveling toward states no
+ estimate, since one taken from that would be a guess wearing the clothes of a fact.
+ """
+ self._stage = stage
+ self._total = total
+ self._emit = emit
+ self._estimator = ETAEstimator(total=total) if estimates and total > UNMEASURED else None
+ self._rate = ReportRate(total)
+
+ def advance(self, completed: int) -> None:
+ """Reports the stage at ``completed`` where a step is due.
+
+ Args:
+ completed: What the stage has covered so far, in the unit the stage counts in.
+ """
+ if not self._rate.take(completed):
+ return
+
+ self._emit(
+ ServiceProgress(
+ completed=completed,
+ total=self._total,
+ current_item=self._stage,
+ eta_seconds=self._estimate(completed),
+ )
+ )
+
+ def _estimate(self, completed: int) -> Optional[float]:
+ if self._estimator is None:
+ return None
+
+ return self._estimator.update(completed)
diff --git a/src/sampletones_application/services/regeneration/__init__.py b/src/sampletones_application/services/regeneration/__init__.py
new file mode 100644
index 000000000..2f6415ed7
--- /dev/null
+++ b/src/sampletones_application/services/regeneration/__init__.py
@@ -0,0 +1,8 @@
+from .result import RegeneratedInstrument, RegenerationResult
+from .service import RegenerationService
+
+__all__ = [
+ "RegeneratedInstrument",
+ "RegenerationResult",
+ "RegenerationService",
+]
diff --git a/src/sampletones_application/services/regeneration/result.py b/src/sampletones_application/services/regeneration/result.py
new file mode 100644
index 000000000..0ec931a67
--- /dev/null
+++ b/src/sampletones_application/services/regeneration/result.py
@@ -0,0 +1,30 @@
+from dataclasses import dataclass
+from typing import Union
+
+from sampletones_application.services.result import (
+ ServiceCanceled,
+ ServiceError,
+ ServiceSuccess,
+)
+from sampletones_core.constants.enums import ChannelName, FeatureKey
+from sampletones_core.reconstructions import Reconstruction
+
+
+@dataclass(frozen=True)
+class RegeneratedInstrument:
+ """A regeneration result paired with the generator and feature that changed.
+
+ Carrying the request context alongside the fresh reconstruction lets the
+ history record which channel and feature an edit touched.
+ """
+
+ reconstruction: Reconstruction
+ channel_name: ChannelName
+ feature_key: FeatureKey
+
+
+RegenerationResult = Union[
+ ServiceSuccess[RegeneratedInstrument],
+ ServiceError,
+ ServiceCanceled,
+]
diff --git a/src/sampletones_application/services/regeneration.py b/src/sampletones_application/services/regeneration/service.py
similarity index 70%
rename from src/sampletones_application/services/regeneration.py
rename to src/sampletones_application/services/regeneration/service.py
index 5602803d6..246206df3 100644
--- a/src/sampletones_application/services/regeneration.py
+++ b/src/sampletones_application/services/regeneration/service.py
@@ -1,37 +1,23 @@
-from dataclasses import dataclass
from typing import List, cast
import numpy as np
from sampletones_application.services.base import ServiceBase
+from sampletones_application.services.regeneration.result import (
+ RegeneratedInstrument,
+ RegenerationResult,
+)
from sampletones_application.services.result import (
- ServiceCancelled,
+ ServiceCanceled,
ServiceError,
ServiceSuccess,
)
from sampletones_application.utils.parallelization.coalescing import LatestWinsExecutor
-from sampletones_core.constants.enums import FeatureKey, GeneratorName
-from sampletones_core.exporters import GENERATOR_NAME_TO_EXPORTER_MAP, Features
+from sampletones_core.constants.enums import ChannelName, FeatureKey
+from sampletones_core.exporters import CHANNEL_TO_EXPORTER_MAP, Features
from sampletones_core.generators import GeneratorUnion
from sampletones_core.instructions import InstructionUnion
from sampletones_core.reconstructions import Reconstruction
-from sampletones_core.types.feature import FeatureValue
-
-
-@dataclass(frozen=True)
-class RegeneratedInstrument:
- """A regeneration result paired with the generator and feature that changed.
-
- Carrying the request context alongside the fresh reconstruction lets the
- history record which channel and feature an edit touched.
- """
-
- reconstruction: Reconstruction
- generator_name: GeneratorName
- feature_key: FeatureKey
-
-
-RegenerationResult = ServiceSuccess[RegeneratedInstrument] | ServiceError | ServiceCancelled
class RegenerationService(ServiceBase[RegenerationResult]):
@@ -51,26 +37,24 @@ class RegenerationService(ServiceBase[RegenerationResult]):
def __init__(self, priority: int = 0) -> None:
super().__init__(priority)
self._executor = LatestWinsExecutor()
- self._cancelled: bool = False
+ self._canceled: bool = False
def start(
self,
reconstruction: Reconstruction,
- generator_name: GeneratorName,
- features: Features,
+ channel_name: ChannelName,
feature_key: FeatureKey,
- value: FeatureValue,
+ features: Features,
) -> bool:
- if self._cancelled:
+ if self._canceled:
return False
return self._executor.submit(
lambda: self._run(
reconstruction,
- generator_name,
- features,
+ channel_name,
feature_key,
- value,
+ features,
)
)
@@ -78,34 +62,31 @@ def is_running(self) -> bool:
return self._executor.is_running
def cancel(self) -> None:
- self._cancelled = True
+ self._canceled = True
def _run(
self,
reconstruction: Reconstruction,
- generator_name: GeneratorName,
- features: Features,
+ channel_name: ChannelName,
feature_key: FeatureKey,
- value: FeatureValue,
+ features: Features,
) -> None:
- if self._cancelled:
- self._emit(ServiceCancelled())
+ if self._canceled:
+ self._emit(ServiceCanceled())
return
try:
- exporter_class = GENERATOR_NAME_TO_EXPORTER_MAP[generator_name]
+ exporter_class = CHANNEL_TO_EXPORTER_MAP[channel_name]
generator_class = exporter_class.get_generator_type()
- features[feature_key] = value
-
instructions = cast(
List[InstructionUnion],
exporter_class.from_features(features),
)
- generator = generator_class(reconstruction.config, generator_name)
+ generator = generator_class(reconstruction.config, channel_name)
audio = self._render(generator, instructions)
updated = reconstruction.model_copy(deep=True)
- updated.update_generator_data(
- generator_name,
+ updated.update_channel_data(
+ channel_name,
instructions,
audio,
features.initial_pitch,
@@ -115,7 +96,7 @@ def _run(
ServiceSuccess(
value=RegeneratedInstrument(
reconstruction=updated,
- generator_name=generator_name,
+ channel_name=channel_name,
feature_key=feature_key,
)
)
diff --git a/src/sampletones_application/services/render/constants.py b/src/sampletones_application/services/render/constants.py
index df1ca36ec..fb0381bdf 100644
--- a/src/sampletones_application/services/render/constants.py
+++ b/src/sampletones_application/services/render/constants.py
@@ -1,5 +1,4 @@
from typing import Final
-PROGRESS_STEPS: Final[int] = 200
ENCODE_BLOCK_SAMPLES: Final[int] = 1 << 16
SCRATCH_SUFFIX: Final[str] = ".scratch"
diff --git a/src/sampletones_application/services/render/progress.py b/src/sampletones_application/services/render/progress.py
deleted file mode 100644
index da09d67e1..000000000
--- a/src/sampletones_application/services/render/progress.py
+++ /dev/null
@@ -1,49 +0,0 @@
-from typing import Callable
-
-from sampletones_application.services.render.constants import PROGRESS_STEPS
-from sampletones_application.services.render.result import RenderStage
-from sampletones_application.services.result import ServiceProgress
-from sampletones_core.parallelization import ETAEstimator
-
-
-class StageProgress:
- """One pass of a render, reported at a bounded rate.
-
- A render walks a song sample by sample, so reporting every step would fill the callback
- queue with updates no eye resolves and no bar redraws. Emitting on a fraction of the total
- holds the report rate steady whatever the song's length, and the last position is always
- reported, so a bar arrives at its end.
- """
-
- def __init__(
- self,
- stage: RenderStage,
- total: int,
- *,
- emit: Callable[[ServiceProgress[RenderStage]], None],
- ) -> None:
- self._stage = stage
- self._total = total
- self._emit = emit
- self._estimator = ETAEstimator(total=total)
- self._interval = max(1, total // PROGRESS_STEPS)
- self._reported: int = 0
-
- def advance(self, completed: int) -> None:
- """Reports the pass at ``completed`` samples where a step is due.
-
- Args:
- completed: The samples this pass has covered so far.
- """
- if completed < self._total and completed - self._reported < self._interval:
- return
-
- self._reported = completed
- self._emit(
- ServiceProgress(
- completed=completed,
- total=self._total,
- current_item=self._stage,
- eta_seconds=self._estimator.update(completed),
- )
- )
diff --git a/src/sampletones_application/services/render/result.py b/src/sampletones_application/services/render/result.py
index 9ee105e5e..1b18accb8 100644
--- a/src/sampletones_application/services/render/result.py
+++ b/src/sampletones_application/services/render/result.py
@@ -3,7 +3,7 @@
from typing import Union
from sampletones_application.services.result import (
- ServiceCancelled,
+ ServiceCanceled,
ServiceError,
ServiceProgress,
ServiceStarted,
@@ -27,5 +27,5 @@ class RenderStage(StrEnum):
ServiceProgress[RenderStage],
ServiceSuccess[Path],
ServiceError,
- ServiceCancelled,
+ ServiceCanceled,
]
diff --git a/src/sampletones_application/services/render/service.py b/src/sampletones_application/services/render/service.py
index fd43c2844..67eaf113a 100644
--- a/src/sampletones_application/services/render/service.py
+++ b/src/sampletones_application/services/render/service.py
@@ -3,7 +3,7 @@
from pathlib import Path
from sampletones_application.services.base import ServiceBase
-from sampletones_application.services.render.progress import StageProgress
+from sampletones_application.services.progress import StageProgress
from sampletones_application.services.render.result import RenderResult, RenderStage
from sampletones_application.services.render.sink import (
EncodeReporter,
@@ -11,7 +11,7 @@
build_render_sink,
)
from sampletones_application.services.result import (
- ServiceCancelled,
+ ServiceCanceled,
ServiceError,
ServiceStarted,
ServiceSuccess,
@@ -31,8 +31,8 @@ class SongRenderService(ServiceBase[RenderResult]):
written back at the level the whole render turned out to reach — so the service reports one
pass or two without knowing which format waits on the other side.
- A render is one at a time. Cancelling is honoured between rows and between encoded blocks,
- and the file a cancelled or failed run was writing is removed, so a result names a path only
+ A render is one at a time. Cancelling is honored between rows and between encoded blocks,
+ and the file a canceled or failed run was writing is removed, so a result names a path only
where a finished file stands.
"""
@@ -133,7 +133,7 @@ def _synthesize(
The song is rendered as the document holds it, from the top: the position a listener left
the playhead at is a listening choice, and a render describes the whole song.
"""
- progress = StageProgress(RenderStage.SYNTHESIS, total_samples, emit=self._emit)
+ progress = StageProgress(RenderStage.SYNTHESIS, total_samples, emit=self._emit, estimates=True)
synthesizer.set_position(0, 0)
synthesizer.reset()
@@ -150,17 +150,17 @@ def _synthesize(
return not self._cancel_event.is_set()
def _encode_reporter(self, total_samples: int) -> EncodeReporter:
- progress = StageProgress(RenderStage.ENCODING, total_samples, emit=self._emit)
+ progress = StageProgress(RenderStage.ENCODING, total_samples, emit=self._emit, estimates=True)
return partial(self._report_encoded, progress)
- def _report_encoded(self, progress: StageProgress, encoded: int) -> bool:
+ def _report_encoded(self, progress: StageProgress[RenderStage], encoded: int) -> bool:
progress.advance(encoded)
return not self._cancel_event.is_set()
def _report_outcome(self, sink: RenderSink, completed: bool) -> None:
if not completed:
sink.discard()
- self._emit(ServiceCancelled())
+ self._emit(ServiceCanceled())
return
logger.info(f"Rendered the song to: {logger.format_path(sink.destination)}")
diff --git a/src/sampletones_application/services/result.py b/src/sampletones_application/services/result.py
index 331013ab3..dfc64e881 100644
--- a/src/sampletones_application/services/result.py
+++ b/src/sampletones_application/services/result.py
@@ -1,11 +1,11 @@
from dataclasses import dataclass
-from pathlib import Path
-from typing import Generic, Optional, TypeVar, Union
-
-from sampletones_core.parallelization import TaskProgress
+from typing import Final, Generic, Optional, TypeVar
T = TypeVar("T")
+NOTHING_TO_DO: Final[int] = 0
+NOTHING_UNDER_WAY: Final[float] = 0.0
+
@dataclass(frozen=True)
class ServiceStarted:
@@ -14,10 +14,33 @@ class ServiceStarted:
@dataclass(frozen=True)
class ServiceProgress(Generic[T]):
+ """How far an operation has come, in the items it is measured in.
+
+ An operation whose items report their own progress states what the one under way has covered
+ as ``partial``, so the run reads as a whole while the counts keep naming the items a reader
+ recognizes.
+
+ Attributes:
+ completed: The items the operation has finished.
+ total: The items the operation is measured against.
+ current_item: What the operation is working on, as the operation names it.
+ eta_seconds: How long the operation has left, where its rate says.
+ partial: The work under way beyond ``completed``, counted in items.
+ """
+
completed: int
total: int
current_item: Optional[T] = None
eta_seconds: Optional[float] = None
+ partial: float = NOTHING_UNDER_WAY
+
+ @property
+ def fraction(self) -> float:
+ """How full the operation stands, the item under way counted for the part of it done."""
+ if self.total == NOTHING_TO_DO:
+ return NOTHING_UNDER_WAY
+
+ return (self.completed + self.partial) / self.total
@dataclass(frozen=True)
@@ -31,20 +54,10 @@ class ServiceError:
@dataclass(frozen=True)
-class ServiceCancelled:
+class ServiceCanceled:
pass
@dataclass(frozen=True)
class ServiceIntermediate(Generic[T]):
data: T
-
-
-ConversionResult = Union[
- ServiceStarted,
- ServiceProgress[Path],
- ServiceIntermediate[TaskProgress],
- ServiceSuccess[Path],
- ServiceError,
- ServiceCancelled,
-]
diff --git a/src/sampletones_application/services/retune/__init__.py b/src/sampletones_application/services/retune/__init__.py
index 963b47d82..eff8e9f1e 100644
--- a/src/sampletones_application/services/retune/__init__.py
+++ b/src/sampletones_application/services/retune/__init__.py
@@ -1,6 +1,6 @@
from sampletones_application.services.retune.result import RetuneResult
-from sampletones_application.services.retune.retune import SampleRetuneService
from sampletones_application.services.retune.sample import RetunedSample
+from sampletones_application.services.retune.service import SampleRetuneService
__all__ = [
"RetuneResult",
diff --git a/src/sampletones_application/services/retune/result.py b/src/sampletones_application/services/retune/result.py
index 98ceccbbd..ef91919af 100644
--- a/src/sampletones_application/services/retune/result.py
+++ b/src/sampletones_application/services/retune/result.py
@@ -1,8 +1,8 @@
from sampletones_application.services.result import (
- ServiceCancelled,
+ ServiceCanceled,
ServiceError,
ServiceSuccess,
)
from sampletones_application.services.retune.sample import RetunedSample
-RetuneResult = ServiceSuccess[RetunedSample] | ServiceError | ServiceCancelled
+RetuneResult = ServiceSuccess[RetunedSample] | ServiceError | ServiceCanceled
diff --git a/src/sampletones_application/services/retune/sample.py b/src/sampletones_application/services/retune/sample.py
index 4dfacbefc..26628ec0d 100644
--- a/src/sampletones_application/services/retune/sample.py
+++ b/src/sampletones_application/services/retune/sample.py
@@ -7,9 +7,9 @@
class RetunedSample:
"""A sample's reconstruction re-synthesized to a new NES frequency.
- Carries the ``sample_id`` so the caller can swap the retuned reconstruction into
+ Carries the ``voice_id`` so the caller can swap the retuned reconstruction into
the right project sample as each result arrives.
"""
- sample_id: str
+ voice_id: str
reconstruction: Reconstruction
diff --git a/src/sampletones_application/services/retune/retune.py b/src/sampletones_application/services/retune/service.py
similarity index 95%
rename from src/sampletones_application/services/retune/retune.py
rename to src/sampletones_application/services/retune/service.py
index 64e6d01e1..ec59998ae 100644
--- a/src/sampletones_application/services/retune/retune.py
+++ b/src/sampletones_application/services/retune/service.py
@@ -37,12 +37,12 @@ def is_running(self) -> bool:
def _run(self, targets: List[RetuneTarget], nes_frequency: int) -> None:
try:
- for sample_id, reconstruction in targets:
+ for voice_id, reconstruction in targets:
retuned = reconstruction.with_nes_frequency(nes_frequency)
self._emit(
ServiceSuccess(
value=RetunedSample(
- sample_id=sample_id,
+ voice_id=voice_id,
reconstruction=retuned,
)
)
diff --git a/src/sampletones_application/services/song_player/player.py b/src/sampletones_application/services/song_player/service.py
similarity index 99%
rename from src/sampletones_application/services/song_player/player.py
rename to src/sampletones_application/services/song_player/service.py
index 2cdc565c5..e4e5351b4 100644
--- a/src/sampletones_application/services/song_player/player.py
+++ b/src/sampletones_application/services/song_player/service.py
@@ -256,7 +256,7 @@ def _play_row(self, stream: pyaudio.Stream, row: _RenderedRow) -> None:
def _write_chunk(self, stream: pyaudio.Stream, chunk: np.ndarray) -> bool:
"""Writes one row to the device in buffer-sized blocks; reports whether it completed.
- Each block is a separate blocking write, so a stop reached mid-row is honoured within
+ Each block is a separate blocking write, so a stop reached mid-row is honored within
roughly one buffer period rather than at the next row boundary. That bounds how long the
writer holds its stream open after a stop, which is what keeps the audio backend safe to
tear down on demand.
diff --git a/src/sampletones_application/shell.py b/src/sampletones_application/shell.py
index 6477969c8..51ade2c2d 100644
--- a/src/sampletones_application/shell.py
+++ b/src/sampletones_application/shell.py
@@ -17,7 +17,7 @@
from sampletones_application.coordinators.tabs.reconstruction import (
ReconstructionTabCoordinator,
)
-from sampletones_application.coordinators.tabs.sequencer import SequencerTabCoordinator
+from sampletones_application.coordinators.tabs.sequencer.coordinator import SequencerTabCoordinator
from sampletones_application.layout import LayoutConfig
from sampletones_application.tags.general import (
TAG_GLOBAL_STATUS_WINDOW,
@@ -51,8 +51,8 @@
from sampletones_application.utils.parallelization.thread import SingleThreadExecutor
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_core.constants.enums import ChannelName
+from sampletones_core.exports.format import ExportFormat
from sampletones_shared.types.application import Sender
from sampletones_shared.types.callback import Callback, PathCallback
@@ -72,7 +72,7 @@ class ShortcutBindings:
save_project: Callback
save_project_as: Callback
project_properties: Callback
- export_project: Callable[[TrackerFormat], None]
+ export_project: Callable[[ExportFormat], None]
render_song: Callback
close_project: Callback
exit: Callback
@@ -87,8 +87,11 @@ class ShortcutBindings:
save_reconstruction_as: Callback
close_reconstruction: Callback
export_wav: Callback
- export_instruments: Callable[[TrackerFormat], None]
+ export_instruments: Callable[[ExportFormat], None]
add_reconstruction_to_sequencer: Callback
+ new_instrument: Callback
+ add_sample_from_file: Callback
+ import_instrument: Callback
open_reconstruction_in_explorer: Callback
locate_original_audio: Callback
play: Callback
@@ -98,7 +101,7 @@ class ShortcutBindings:
toggle_autoplay: Callback
set_follow_mode: Callable[[FollowMode], None]
toggle_loop_song: Callback
- toggle_channel: Callable[[GeneratorName], None]
+ toggle_channel: Callable[[ChannelName], None]
unmute_all_channels: Callback
audio_settings: Callback
display_settings: Callback
@@ -119,7 +122,7 @@ class ApplicationShell:
It serves two roles:
- - *Lifecycle* — encodes the DPG initialisation sequence in ``setup()`` and
+ - *Lifecycle* — encodes the DPG initialization sequence in ``setup()`` and
hides it behind a clean boundary.
- *Runtime* — tab router, shortcut dispatcher, and per-frame UI driver.
@@ -241,6 +244,9 @@ def _shortcut_callbacks(bindings: ShortcutBindings) -> Dict[ShortcutId, Callback
ShortcutId.CLOSE_RECONSTRUCTION: bindings.close_reconstruction,
ShortcutId.EXPORT_RECONSTRUCTION_WAV: bindings.export_wav,
ShortcutId.ADD_RECONSTRUCTION_TO_SEQUENCER: bindings.add_reconstruction_to_sequencer,
+ ShortcutId.NEW_INSTRUMENT: bindings.new_instrument,
+ ShortcutId.ADD_SAMPLE_FROM_FILE: bindings.add_sample_from_file,
+ ShortcutId.IMPORT_INSTRUMENT: bindings.import_instrument,
ShortcutId.OPEN_RECONSTRUCTION_IN_EXPLORER: bindings.open_reconstruction_in_explorer,
ShortcutId.LOCATE_ORIGINAL_AUDIO: bindings.locate_original_audio,
ShortcutId.PLAY: bindings.play,
@@ -271,18 +277,18 @@ def _shortcut_callbacks(bindings: ShortcutBindings) -> Dict[ShortcutId, Callback
def _export_callbacks(
bindings: ShortcutBindings,
) -> Dict[ShortcutId, Callback]:
- """One export action per tracker format, the entries the Export submenus list.
+ """One export action per 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.
"""
project = {
- shortcut_id: partial(bindings.export_project, tracker_format)
- for tracker_format, shortcut_id in PROJECT_EXPORT_SHORTCUT_IDS.items()
+ shortcut_id: partial(bindings.export_project, export_format)
+ for export_format, shortcut_id in PROJECT_EXPORT_SHORTCUT_IDS.items()
}
instruments = {
- shortcut_id: partial(bindings.export_instruments, tracker_format)
- for tracker_format, shortcut_id in SAMPLE_EXPORT_SHORTCUT_IDS.items()
+ shortcut_id: partial(bindings.export_instruments, export_format)
+ for export_format, shortcut_id in SAMPLE_EXPORT_SHORTCUT_IDS.items()
}
return {**project, **instruments}
diff --git a/src/sampletones_application/tags/general.py b/src/sampletones_application/tags/general.py
index 05eac7e15..996f1212d 100644
--- a/src/sampletones_application/tags/general.py
+++ b/src/sampletones_application/tags/general.py
@@ -260,6 +260,30 @@
Widget.THEME,
"plus_minus_buttons",
)
+TAG_GLOBAL_THEME_CHANNEL_MUTED = TagName(
+ Page.GLOBAL,
+ Panel.IMPLICIT,
+ Widget.THEME,
+ "channel_muted",
+)
+TAG_GLOBAL_THEME_STEMS_DROP_STRIP = TagName(
+ Page.GLOBAL,
+ Panel.IMPLICIT,
+ Widget.THEME,
+ "stems_drop_strip",
+)
+TAG_GLOBAL_THEME_STEMS_ROW = TagName(
+ Page.GLOBAL,
+ Panel.IMPLICIT,
+ Widget.THEME,
+ "stems_row",
+)
+TAG_GLOBAL_THEME_STEMS_ROW_INERT = TagName(
+ Page.GLOBAL,
+ Panel.IMPLICIT,
+ Widget.THEME,
+ "stems_row_inert",
+)
TAG_GLOBAL_THEME_TOOLTIP = TagName(
Page.GLOBAL,
Panel.IMPLICIT,
@@ -476,6 +500,12 @@
Widget.DIALOG,
"project_unsaved",
)
+TAG_GLOBAL_DIALOG_INSTRUMENT_IMPORTED = TagName(
+ Page.GLOBAL,
+ Panel.IMPLICIT,
+ Widget.DIALOG,
+ "instrument_imported",
+)
TAG_GLOBAL_DIALOG_NO_PROJECT_OPEN = TagName(
Page.GLOBAL,
Panel.IMPLICIT,
@@ -524,6 +554,42 @@
Widget.MENU,
"item_reconstruction_export_instruments",
)
+TAG_GLOBAL_MENU_GROUP_VOICE = TagName(
+ Page.GLOBAL,
+ Panel.IMPLICIT,
+ Widget.MENU,
+ "group_voice",
+)
+TAG_GLOBAL_MENU_GROUP_VOICE_MARKER = TagName(
+ Page.GLOBAL,
+ Panel.IMPLICIT,
+ Widget.MENU,
+ "group_voice_marker",
+)
+TAG_GLOBAL_MENU_ITEM_VOICE_NEW_INSTRUMENT = TagName(
+ Page.GLOBAL,
+ Panel.IMPLICIT,
+ Widget.MENU,
+ "item_voice_new_instrument",
+)
+TAG_GLOBAL_MENU_ITEM_VOICE_ADD_SAMPLE = TagName(
+ Page.GLOBAL,
+ Panel.IMPLICIT,
+ Widget.MENU,
+ "item_voice_add_sample",
+)
+TAG_GLOBAL_MENU_ITEM_VOICE_IMPORT_INSTRUMENT = TagName(
+ Page.GLOBAL,
+ Panel.IMPLICIT,
+ Widget.MENU,
+ "item_voice_import_instrument",
+)
+TAG_GLOBAL_MENU_ITEM_VOICE_ADD_TO_SEQUENCER = TagName(
+ Page.GLOBAL,
+ Panel.IMPLICIT,
+ Widget.MENU,
+ "item_voice_add_to_sequencer",
+)
TAG_GLOBAL_MENU_ITEM_RECONSTRUCTION_ADD_TO_SEQUENCER = TagName(
Page.GLOBAL,
Panel.IMPLICIT,
@@ -699,6 +765,7 @@
SUF_BUTTON_SHOW_TRACEBACK = compose_tag(SUF_BUTTON, "show_traceback")
SUF_BUTTON_DECREMENT = compose_tag(SUF_BUTTON, "decrement")
SUF_BUTTON_INCREMENT = compose_tag(SUF_BUTTON, "increment")
+SUF_CHANNELS = "channels"
SUF_GROUP = "group"
SUF_GROUP_TRACEBACK = compose_tag(SUF_GROUP, "traceback")
SUF_HANDLER_REGISTRY = compose_tag("handler", "registry")
@@ -707,6 +774,7 @@
SUF_HANDLER_DETAIL_TOOLTIP = compose_tag("handler", "detail_tooltip")
SUF_HANDLER_HEADER = compose_tag("handler", "header")
SUF_HANDLER_DRAG = compose_tag("handler", "drag")
+SUF_HANDLER_LIST = compose_tag("handler", "list")
SUF_LABEL = "label"
SUF_PATH = "path"
SUF_TEXT = "text"
@@ -715,6 +783,7 @@
SUF_INPUT_SEARCH = compose_tag(SUF_INPUT, "search")
SUF_CHECKBOX = "checkbox"
SUF_CHECKBOX_FAVORITES = compose_tag(SUF_CHECKBOX, "favorites")
+SUF_STRIP = "strip"
SUF_TABLE = "table"
SUF_TOOLTIP = "tooltip"
SUF_TOOLTIP_DETAIL = compose_tag(SUF_TOOLTIP, "detail")
@@ -726,3 +795,7 @@
SUF_COLLAPSE_BODY = compose_tag("collapse", "body")
SUF_COLLAPSE_RAIL = compose_tag("collapse", "rail")
SUF_COLLAPSE_CHEVRON = compose_tag("collapse", "chevron")
+SUF_ROW = "row"
+SUF_LEVEL = "level"
+SUF_WELL = "well"
+SUF_PAYLOAD = "payload"
diff --git a/src/sampletones_application/tags/main.py b/src/sampletones_application/tags/main.py
index f50d7bea7..71650ce53 100644
--- a/src/sampletones_application/tags/main.py
+++ b/src/sampletones_application/tags/main.py
@@ -1,5 +1,6 @@
from sampletones_application.categories.hierarchy import Page, Panel, Widget
from sampletones_application.categories.key.tag import TagName
+from sampletones_application.tags.compose import compose_tag
TAG_MAIN_CONFIG_PANEL_CONFIG_CELL = TagName(
Page.MAIN,
@@ -229,12 +230,6 @@
Widget.TOOLTIP,
"convert",
)
-TAG_MAIN_CONVERTER_WINDOW_SUMMARY = TagName(
- Page.MAIN,
- Panel.CONVERTER,
- Widget.WINDOW,
- "summary",
-)
TAG_MAIN_CONVERTER_GROUP_SUMMARY = TagName(
Page.MAIN,
Panel.CONVERTER,
@@ -248,4 +243,110 @@
"summary_hint",
)
-PRE_MAIN_RECONSTRUCTOR_GENERATOR = "gen"
+PRE_MAIN_RECONSTRUCTOR_CHANNEL = "channel"
+TAG_MAIN_CONVERTER_GROUP_CONTROLS = TagName(
+ Page.MAIN,
+ Panel.CONVERTER,
+ Widget.GROUP,
+ "controls",
+)
+TAG_MAIN_CONVERTER_CHECKBOX_STEMS_MODE = TagName(
+ Page.MAIN,
+ Panel.CONVERTER,
+ Widget.CHECKBOX,
+ "stems_mode",
+)
+TAG_MAIN_CONVERTER_INPUT_CHANNEL_CAP = TagName(
+ Page.MAIN,
+ Panel.CONVERTER,
+ Widget.INPUT,
+ "channel_cap",
+)
+TAG_MAIN_CONVERTER_COMBO_HIERARCHY_MODE = TagName(
+ Page.MAIN,
+ Panel.CONVERTER,
+ Widget.COMBO,
+ "hierarchy_mode",
+)
+TAG_MAIN_CONVERTER_TOOLTIP_STEMS_MODE = TagName(
+ Page.MAIN,
+ Panel.CONVERTER,
+ Widget.TOOLTIP,
+ "stems_mode",
+)
+TAG_MAIN_CONVERTER_TOOLTIP_CHANNEL_CAP = TagName(
+ Page.MAIN,
+ Panel.CONVERTER,
+ Widget.TOOLTIP,
+ "channel_cap",
+)
+TAG_MAIN_CONVERTER_TOOLTIP_HIERARCHY_MODE = TagName(
+ Page.MAIN,
+ Panel.CONVERTER,
+ Widget.TOOLTIP,
+ "hierarchy_mode",
+)
+TAG_MAIN_CONVERTER_WINDOW_STEMS = TagName(
+ Page.MAIN,
+ Panel.CONVERTER,
+ Widget.WINDOW,
+ "stems",
+)
+TAG_MAIN_CONVERTER_TEXT_STEMS_HINT = TagName(
+ Page.MAIN,
+ Panel.CONVERTER,
+ Widget.TEXT,
+ "stems_hint",
+)
+TAG_MAIN_CONVERTER_DIALOG_DISCARD_STEMS = TagName(
+ Page.MAIN,
+ Panel.CONVERTER,
+ Widget.DIALOG,
+ "discard_stems",
+)
+TAG_MAIN_CONVERTER_DIALOG_OVERWRITE_TARGET = TagName(
+ Page.MAIN,
+ Panel.CONVERTER,
+ Widget.DIALOG,
+ "overwrite_target",
+)
+TAG_MAIN_CONVERTER_WINDOW_STEM_SELECTION = TagName(
+ Page.MAIN,
+ Panel.CONVERTER,
+ Widget.WINDOW,
+ "stem_selection",
+)
+TAG_MAIN_CONVERTER_TEXT_STEM_SELECTION_LIMIT = TagName(
+ Page.MAIN,
+ Panel.CONVERTER,
+ Widget.TEXT,
+ "stem_selection_limit",
+)
+TAG_MAIN_CONVERTER_GROUP_STEM_SELECTION = TagName(
+ Page.MAIN,
+ Panel.CONVERTER,
+ Widget.GROUP,
+ "stem_selection",
+)
+TAG_MAIN_CONVERTER_BUTTON_ADD_STEMS = TagName(
+ Page.MAIN,
+ Panel.CONVERTER,
+ Widget.BUTTON,
+ "add_stems",
+)
+TAG_MAIN_CONVERTER_BUTTON_CANCEL_STEMS = TagName(
+ Page.MAIN,
+ Panel.CONVERTER,
+ Widget.BUTTON,
+ "cancel_stems",
+)
+PRE_MAIN_CONVERTER_STEMS = compose_tag(
+ Page.MAIN,
+ Panel.CONVERTER,
+ "stems",
+)
+PRE_MAIN_CONVERTER_CANDIDATE = compose_tag(
+ Page.MAIN,
+ Panel.CONVERTER,
+ "candidate",
+)
diff --git a/src/sampletones_application/tags/reconstructions.py b/src/sampletones_application/tags/reconstructions.py
index 90d3f9e03..003e8ef06 100644
--- a/src/sampletones_application/tags/reconstructions.py
+++ b/src/sampletones_application/tags/reconstructions.py
@@ -56,6 +56,12 @@
Widget.DIALOG,
"remove_directory_confirmation",
)
+TAG_RECONSTRUCTIONS_RECONSTRUCTION_DIALOG_REMOVE_STEM_CONFIRMATION = TagName(
+ Page.RECONSTRUCTIONS,
+ Panel.RECONSTRUCTION,
+ Widget.DIALOG,
+ "remove_stem_confirmation",
+)
TAG_RECONSTRUCTIONS_RECONSTRUCTION_PANEL_RECONSTRUCTION_WAVEFORM = TagName(
Page.RECONSTRUCTIONS,
Panel.RECONSTRUCTION,
@@ -74,11 +80,11 @@
Widget.PANEL,
"plot",
)
-TAG_RECONSTRUCTIONS_RECONSTRUCTION_GROUP_GENERATORS = TagName(
+TAG_RECONSTRUCTIONS_RECONSTRUCTION_GROUP_CHANNELS = TagName(
Page.RECONSTRUCTIONS,
Panel.RECONSTRUCTION,
Widget.GROUP,
- "generators",
+ "channels",
)
TAG_RECONSTRUCTIONS_RECONSTRUCTION_GROUP_AUDIO_SOURCE = TagName(
Page.RECONSTRUCTIONS,
@@ -98,11 +104,41 @@
Widget.PATH,
"reconstruction_file",
)
-TAG_RECONSTRUCTIONS_RECONSTRUCTION_PATH_ORIGINAL_AUDIO = TagName(
+TAG_RECONSTRUCTIONS_RECONSTRUCTION_TEXT_NES_FREQUENCY = TagName(
Page.RECONSTRUCTIONS,
Panel.RECONSTRUCTION,
- Widget.PATH,
- "original_audio",
+ Widget.TEXT,
+ "nes_frequency",
+)
+TAG_RECONSTRUCTIONS_RECONSTRUCTION_PANEL_STEMS = TagName(
+ Page.RECONSTRUCTIONS,
+ Panel.RECONSTRUCTION,
+ Widget.PANEL,
+ "stems",
+)
+TAG_RECONSTRUCTIONS_RECONSTRUCTION_TEXT_STEMS_SETUP = TagName(
+ Page.RECONSTRUCTIONS,
+ Panel.RECONSTRUCTION,
+ Widget.TEXT,
+ "stems_setup",
+)
+TAG_RECONSTRUCTIONS_RECONSTRUCTION_TEXT_STEMS_EMPTY = TagName(
+ Page.RECONSTRUCTIONS,
+ Panel.RECONSTRUCTION,
+ Widget.TEXT,
+ "stems_empty",
+)
+TAG_RECONSTRUCTIONS_RECONSTRUCTION_CHECKBOX_COLLAPSE_LEVELS = TagName(
+ Page.RECONSTRUCTIONS,
+ Panel.RECONSTRUCTION,
+ Widget.CHECKBOX,
+ "collapse_levels",
+)
+TAG_RECONSTRUCTIONS_RECONSTRUCTION_TOOLTIP_COLLAPSE_LEVELS = TagName(
+ Page.RECONSTRUCTIONS,
+ Panel.RECONSTRUCTION,
+ Widget.TOOLTIP,
+ "collapse_levels",
)
TAG_RECONSTRUCTIONS_INSTRUMENTS_PANEL = TagName(
Page.RECONSTRUCTIONS,
@@ -128,8 +164,15 @@
Widget.TEXT,
"sample_size",
)
+TAG_RECONSTRUCTIONS_INSTRUMENTS_RADIO_AUDITION = TagName(
+ Page.RECONSTRUCTIONS,
+ Panel.INSTRUMENTS,
+ Widget.RADIO,
+ "audition",
+)
-PRE_RECONSTRUCTION_GENERATOR = compose_tag("reconstruction", "generator")
+PRE_RECONSTRUCTION_CHANNEL = compose_tag("reconstruction", "channel")
+PRE_RECONSTRUCTION_STEMS = compose_tag("reconstruction", "stems")
SUF_RECONSTRUCTIONS_INSTRUMENTS_NO_DATA_MESSAGE = "no_data_message"
SUF_RECONSTRUCTIONS_INSTRUMENTS_INSTRUMENT_SIZE = "instrument_size"
SUF_RECONSTRUCTIONS_INSTRUMENTS_WINDOW = "window"
diff --git a/src/sampletones_application/tags/sequencer.py b/src/sampletones_application/tags/sequencer.py
index 0fddc3982..9f7499b13 100644
--- a/src/sampletones_application/tags/sequencer.py
+++ b/src/sampletones_application/tags/sequencer.py
@@ -140,39 +140,51 @@
Widget.BUTTON,
"pair",
)
-TAG_SEQUENCER_INSTRUMENTS_PANEL = TagName(
+TAG_SEQUENCER_TRACKER_INPUT_OCTAVE = TagName(
Page.SEQUENCER,
- Panel.INSTRUMENTS,
+ Panel.TRACKER,
+ Widget.INPUT,
+ "octave",
+)
+TAG_SEQUENCER_VOICES_PANEL = TagName(
+ Page.SEQUENCER,
+ Panel.VOICES,
Widget.PANEL,
- "instruments",
+ "voices",
+)
+TAG_SEQUENCER_VOICES_BUTTON_NEW_INSTRUMENT = TagName(
+ Page.SEQUENCER,
+ Panel.VOICES,
+ Widget.BUTTON,
+ "new_instrument",
)
-TAG_SEQUENCER_INSTRUMENTS_TABLE = TagName(
+TAG_SEQUENCER_VOICES_TABLE = TagName(
Page.SEQUENCER,
- Panel.INSTRUMENTS,
+ Panel.VOICES,
Widget.TABLE,
- "instruments",
+ "voices",
)
-TAG_SEQUENCER_INSTRUMENTS_WINDOW = TagName(
+TAG_SEQUENCER_VOICES_WINDOW = TagName(
Page.SEQUENCER,
- Panel.INSTRUMENTS,
+ Panel.VOICES,
Widget.WINDOW,
- "instruments",
+ "voices",
)
-TAG_SEQUENCER_INSTRUMENTS_THEME_ROW = TagName(
+TAG_SEQUENCER_VOICES_THEME_ROW = TagName(
Page.SEQUENCER,
- Panel.INSTRUMENTS,
+ Panel.VOICES,
Widget.THEME,
"row",
)
-TAG_SEQUENCER_INSTRUMENTS_DIALOG_REMOVE = TagName(
+TAG_SEQUENCER_VOICES_DIALOG_REMOVE = TagName(
Page.SEQUENCER,
- Panel.INSTRUMENTS,
+ Panel.VOICES,
Widget.DIALOG,
"remove",
)
-TAG_SEQUENCER_INSTRUMENTS_INPUT_RENAME = TagName(
+TAG_SEQUENCER_VOICES_INPUT_RENAME = TagName(
Page.SEQUENCER,
- Panel.INSTRUMENTS,
+ Panel.VOICES,
Widget.INPUT,
"rename",
)
diff --git a/src/sampletones_application/tags/settings.py b/src/sampletones_application/tags/settings.py
index 8a48c2f2e..5c84aee27 100644
--- a/src/sampletones_application/tags/settings.py
+++ b/src/sampletones_application/tags/settings.py
@@ -390,3 +390,51 @@
Widget.BUTTON,
"cancel",
)
+TAG_SETTINGS_EXPORT_WINDOW = TagName(
+ Page.SETTINGS,
+ Panel.EXPORT,
+ Widget.WINDOW,
+ "export",
+)
+TAG_SETTINGS_EXPORT_GROUP_STAGES = TagName(
+ Page.SETTINGS,
+ Panel.EXPORT,
+ Widget.GROUP,
+ "stages",
+)
+TAG_SETTINGS_EXPORT_GROUP_MEASURED = TagName(
+ Page.SETTINGS,
+ Panel.EXPORT,
+ Widget.GROUP,
+ "measured",
+)
+TAG_SETTINGS_EXPORT_GROUP_WORKING = TagName(
+ Page.SETTINGS,
+ Panel.EXPORT,
+ Widget.GROUP,
+ "working",
+)
+TAG_SETTINGS_EXPORT_TEXT_STAGE = TagName(
+ Page.SETTINGS,
+ Panel.EXPORT,
+ Widget.TEXT,
+ "stage",
+)
+TAG_SETTINGS_EXPORT_TEXT_FIGURE = TagName(
+ Page.SETTINGS,
+ Panel.EXPORT,
+ Widget.TEXT,
+ "figure",
+)
+TAG_SETTINGS_EXPORT_PROGRESS = TagName(
+ Page.SETTINGS,
+ Panel.EXPORT,
+ Widget.PROGRESS,
+ "export",
+)
+TAG_SETTINGS_EXPORT_BUTTON_CANCEL = TagName(
+ Page.SETTINGS,
+ Panel.EXPORT,
+ Widget.BUTTON,
+ "cancel",
+)
diff --git a/src/sampletones_application/ui/elements/context_menu.py b/src/sampletones_application/ui/elements/context_menu.py
index e63e62f56..ca1710623 100644
--- a/src/sampletones_application/ui/elements/context_menu.py
+++ b/src/sampletones_application/ui/elements/context_menu.py
@@ -1,14 +1,17 @@
import contextlib
+from pathlib import Path
from typing import Iterator, Optional, Sequence, Tuple
import dearpygui.dearpygui as dpg
+from sampletones_application.categories.manager import LanguageManager
from sampletones_application.ui.elements.fonts.font import Font
from sampletones_application.ui.elements.fonts.registry import FontRegistry
from sampletones_application.utils.gui.palette.dpg import dpg_set_palette_color
from sampletones_application.utils.gui.tooltip import show_tooltip
from sampletones_application.utils.palette.colors.base import BaseColor
from sampletones_shared.types.callback import VoidCallback
+from sampletones_shared.utils.system.paths import open_path_in_explorer
@contextlib.contextmanager
@@ -78,3 +81,28 @@ def add_detail_items(
FontRegistry.bind_to_item(detail_text, Font.MONO_SMALL)
if tooltip is not None:
show_tooltip(detail_text, tooltip)
+
+
+def add_path_menu_items(
+ language_manager: LanguageManager,
+ path: Path,
+) -> None:
+ """Add the shared block of filesystem actions for ``path`` to the context menu being built.
+
+ Every menu naming something on disk offers the same three: the name and the full path to the
+ clipboard, and the file revealed in the file manager. Routing the file browsers and the
+ converter's gathered recordings through one builder keeps them reading alike.
+ """
+ dpg.add_separator()
+ dpg.add_menu_item(
+ label=language_manager["global.context.label.copy_filename"],
+ callback=lambda: dpg.set_clipboard_text(str(path.name)),
+ )
+ dpg.add_menu_item(
+ label=language_manager["global.context.label.copy_path"],
+ callback=lambda: dpg.set_clipboard_text(str(path)),
+ )
+ dpg.add_menu_item(
+ label=language_manager["global.context.label.open_in_explorer"],
+ callback=lambda: open_path_in_explorer(path),
+ )
diff --git a/src/sampletones_application/ui/elements/field.py b/src/sampletones_application/ui/elements/field.py
index 2e282f0c2..493df0ad8 100644
--- a/src/sampletones_application/ui/elements/field.py
+++ b/src/sampletones_application/ui/elements/field.py
@@ -18,7 +18,7 @@ def labeled_field(
"""Lay out a widget as ``label [ widget ]`` with the label in a fixed-width column.
Opens a horizontal group holding the label text, pads it to ``label_width`` so the
- widget aligns with its neighbours, and yields inside the group for the caller to
+ widget aligns with its neighbors, and yields inside the group for the caller to
create the widget. The caller's widget omits its own ``label``.
A ``font`` binds the label to that weight; leaving it unset keeps the default weight.
@@ -39,8 +39,8 @@ def labeled_field(
def subheader(label: str, *, parent: Union[int, str] = 0) -> None:
"""Render a bold subheader that groups the fields beneath it within a card.
- Uses the default body colour at bold weight, sitting a level below the card's section
- header so related fields read as one labelled group under a clear caption.
+ Uses the default body color at bold weight, sitting a level below the card's section
+ header so related fields read as one labeled group under a clear caption.
"""
label_id = dpg.add_text(label, parent=parent)
FontRegistry.bind_to_item(label_id, Font.BOLD)
diff --git a/src/sampletones_application/ui/elements/graphs/graph.py b/src/sampletones_application/ui/elements/graphs/graph.py
index 19e9c21ce..94e628363 100644
--- a/src/sampletones_application/ui/elements/graphs/graph.py
+++ b/src/sampletones_application/ui/elements/graphs/graph.py
@@ -17,6 +17,7 @@
from sampletones_application.ui.elements.graphs.layers.type import LayerT
from sampletones_application.ui.elements.panel import GUIPanel
from sampletones_application.utils.gui.dpg import dpg_configure_item
+from sampletones_application.utils.gui.frame import FrameCallbackManager
from sampletones_shared.types.application import Sender
@@ -145,7 +146,15 @@ def _update_axes_limits(self) -> None:
dpg.set_axis_limits_constraints(self.y_axis_tag, *self.y_range)
def _release_axes_limits(self) -> None:
- dpg.split_frame()
+ """Hands the axes back to the data once the frame carrying the new locks has been drawn.
+
+ A lock reaches an axis when the frame stating it renders, so the release follows a frame
+ behind it. It is scheduled rather than waited for, which leaves the render thread free to
+ draw that frame.
+ """
+ FrameCallbackManager.set_frame_callback(self._set_axes_auto)
+
+ def _set_axes_auto(self) -> None:
dpg.set_axis_limits_auto(self.x_axis_tag)
dpg.set_axis_limits_auto(self.y_axis_tag)
diff --git a/tests/unit/sampletones_core/project/instruments/__init__.py b/src/sampletones_application/ui/elements/graphs/layers/__init__.py
similarity index 100%
rename from tests/unit/sampletones_core/project/instruments/__init__.py
rename to src/sampletones_application/ui/elements/graphs/layers/__init__.py
diff --git a/src/sampletones_application/ui/elements/graphs/spectrum.py b/src/sampletones_application/ui/elements/graphs/spectrum.py
index d36cc0a22..e1a9e89bd 100644
--- a/src/sampletones_application/ui/elements/graphs/spectrum.py
+++ b/src/sampletones_application/ui/elements/graphs/spectrum.py
@@ -97,7 +97,16 @@ def load_library_fragment(
fragment: InstructionLibraryFragment[Any],
_sample_rate: int,
_frame_length: int,
+ color: BaseColor,
) -> None:
+ """Draws one library fragment's bands, brightening toward the color of its generator.
+
+ Args:
+ fragment: The fragment to draw.
+ _sample_rate: The rate the fragment was sampled at.
+ _frame_length: The samples one frame spans.
+ color: The bright end of the gradient a band's shade sits on.
+ """
self.clear_layers()
self.add_layer(
@@ -106,7 +115,7 @@ def load_library_fragment(
name=self._language_manager["global.graph.label.spectrum_name"],
max_display_bins=self._layout.spectrum.max_display_bins,
color_dim=self._layout.spectrum.color_dim,
- color_bright=self._layout.spectrum.color_bright,
+ color_bright=color,
)
)
diff --git a/src/sampletones_application/ui/elements/graphs/waveform.py b/src/sampletones_application/ui/elements/graphs/waveform.py
index a782ab12c..c308a08bb 100644
--- a/src/sampletones_application/ui/elements/graphs/waveform.py
+++ b/src/sampletones_application/ui/elements/graphs/waveform.py
@@ -1,5 +1,5 @@
from enum import StrEnum
-from typing import Any, List, Optional, Tuple, Union
+from typing import Any, Dict, List, Optional, Tuple, Union
import dearpygui.dearpygui as dpg
import numpy as np
@@ -32,13 +32,13 @@
from sampletones_application.utils.palette.colors.faded import FadedColor
from sampletones_application.utils.palette.colors.grayscale import GrayscaleColor
from sampletones_application.view_model.shared.waveform_data import WaveformData
-from sampletones_core.constants.enums import AudioSourceType, GeneratorName
+from sampletones_core.constants.enums import AudioSourceType, ChannelName
from sampletones_core.library import InstructionLibraryFragment
from sampletones_shared.types.application import Sender
class SeriesShade(StrEnum):
- """How strongly a waveform series is drawn, which decides the colour its theme carries."""
+ """How strongly a waveform series is drawn, which decides the color its theme carries."""
FULL = "full"
DIMMED = "dimmed"
@@ -79,6 +79,7 @@ def __init__(
self.overlay_theme = ThemeRegistry.get(TAG_GLOBAL_GRAPH_THEME_OVERLAY)
self.current_data: Optional[Union[InstructionLibraryFragment[Any], WaveformData]] = None
+ self._series_themes: Dict[BaseColor, str] = {}
self.current_position: int = 0
_min_x = layout.graph.min_x
@@ -172,7 +173,17 @@ def _set_overlay_rectangle(self, x_start: float = 0.0, x_end: float = 0.0) -> No
y2=[_max_y, _max_y],
)
- def load_library_fragment(self, fragment: InstructionLibraryFragment[Any]) -> None:
+ def load_library_fragment(
+ self,
+ fragment: InstructionLibraryFragment[Any],
+ color: BaseColor,
+ ) -> None:
+ """Draws one library fragment, in the color the generator that made it is known by.
+
+ Args:
+ fragment: The fragment to draw.
+ color: The color the line is drawn in.
+ """
self.clear_layers()
self.current_data = fragment
self.current_position = 0
@@ -181,23 +192,52 @@ def load_library_fragment(self, fragment: InstructionLibraryFragment[Any]) -> No
InstructionLayer(
data=fragment,
name=self._language_manager["global.graph.label.waveform_sample_name"],
- color=self._layout.colors.waveform_sample,
+ color=color,
)
)
self._update_axes_limits()
self._update_position_indicator()
+ def load_voice_waveform(
+ self,
+ audio: np.ndarray,
+ *,
+ name: str,
+ color: BaseColor,
+ ) -> None:
+ """Draws one voice's own audio as a single series, in the color its generator is named by.
+
+ A hand-written voice stands on no recording, so there is nothing to hold it against: the
+ card shows what its envelopes make, labeled with the voice's own name. The series is the
+ whole of what is drawn, so the controls that read a recording apply to nothing here.
+
+ Args:
+ audio: The waveform to draw.
+ name: The name the series is labeled by.
+ color: The color the line is drawn in.
+ """
+ self._reconstruction_dimmed = False
+ self.clear_layers()
+ self.add_layer(
+ ArrayLayer(
+ data=audio,
+ name=name,
+ color=color,
+ max_display_points=self._layout.waveform.max_display_points,
+ )
+ )
+
def _extract_reconstruction_layer_data(
self,
waveform_data: WaveformData,
- selected_generators: Optional[List[GeneratorName]] = None,
+ selected_channels: Optional[List[ChannelName]] = None,
) -> Tuple[Optional[np.ndarray], np.ndarray, float]:
- if selected_generators is None:
- selected_generators = list(waveform_data.approximations.keys())
+ if selected_channels is None:
+ selected_channels = list(waveform_data.approximations.keys())
original_audio = waveform_data.original_audio
- approximation = waveform_data.partials(selected_generators)
+ approximation = waveform_data.partials(selected_channels)
full_approximation = waveform_data.approximation
if not self.reconstruction_autoscale or original_audio is None:
@@ -215,7 +255,7 @@ def _extract_reconstruction_layer_data(
def _display_layers(
self,
waveform_data: WaveformData,
- selected_generators: Optional[List[GeneratorName]] = None,
+ selected_channels: Optional[List[ChannelName]] = None,
) -> List[Union[ArrayLayer, InstructionLayer]]:
"""Builds the ordered waveform layers for the current data.
@@ -225,7 +265,7 @@ def _display_layers(
"""
original_audio, approximation_data, _ = self._extract_reconstruction_layer_data(
waveform_data,
- selected_generators,
+ selected_channels,
)
reconstruction_layer = self.reconstruction_layer(approximation_data)
if original_audio is None:
@@ -237,13 +277,13 @@ def _display_layers(
def update_waveform_data(
self,
waveform_data: WaveformData,
- selected_generators: Optional[List[GeneratorName]] = None,
+ selected_channels: Optional[List[ChannelName]] = None,
) -> None:
if not isinstance(self.current_data, WaveformData):
return
self.current_data = waveform_data
- for layer in self._display_layers(waveform_data, selected_generators):
+ for layer in self._display_layers(waveform_data, selected_channels):
self.layers[layer.name] = layer
self._update_display()
@@ -251,21 +291,21 @@ def update_waveform_data(
def load_waveform_data(
self,
waveform_data: WaveformData,
- selected_generators: Optional[List[GeneratorName]] = None,
+ selected_channels: Optional[List[ChannelName]] = None,
) -> None:
self._reconstruction_dimmed = False
self.clear_layers()
self.current_data = waveform_data
- for layer in self._display_layers(waveform_data, selected_generators):
+ for layer in self._display_layers(waveform_data, selected_channels):
self.add_layer(layer)
def set_reconstruction_dimmed(self, dimmed: bool) -> None:
- """Greys the reconstruction line while its audio is being regenerated, restoring it when done.
+ """Grays the reconstruction line while its audio is being regenerated, restoring it when done.
- Only the reconstruction series is greyed; the original-audio series and the axes keep full
+ Only the reconstruction series is grayed; the original-audio series and the axes keep full
strength, so the fade reads as "this waveform is being recomputed", and the status bar shows a
regenerating hint for the same span. The state is remembered so an async data update arriving
- mid-regeneration redraws the reconstruction still greyed.
+ mid-regeneration redraws the reconstruction still grayed.
"""
if self._reconstruction_dimmed == dimmed:
return
@@ -370,7 +410,7 @@ def _series_color(
layer: Union[ArrayLayer, InstructionLayer],
shade: SeriesShade,
) -> BaseColor:
- """A layer's line colour in one of its two shades.
+ """A layer's line color in one of its two shades.
The dimmed reconstruction is desaturated to gray and faded, so the drawn waveform — not just
the legend swatch — clearly reads as inactive while its audio is recomputed.
@@ -423,23 +463,39 @@ def _bind_series_theme(
series_tag: str,
layer: Union[ArrayLayer, InstructionLayer],
) -> None:
- """Binds a line-colour theme to a series, holding one theme per shade the series takes.
+ """Binds the theme drawing this layer in the shade it currently takes."""
+ shade = self._series_shade(layer)
+ dpg_bind_item_theme(series_tag, self._series_theme(self._series_color(layer, shade)))
+
+ def _series_theme(self, color: BaseColor) -> str:
+ """The theme drawing a line in one color, built once per color the graph has shown.
- A series switches between its full and dimmed shades — the reconstruction line greys while
- its audio is recomputed — by binding the theme built for that shade, and each theme carries
- the colour token behind its shade, so both follow a palette swap.
+ A layer keeps its name across loads while its color follows what it draws — one
+ generator's fragment after another's, the reconstruction line graying as its audio is
+ recomputed — so the theme is held against the color rather than against the series that
+ carries it, and a layer arriving in a new color binds the theme built for that color. Each
+ theme holds the color token itself, so every color the graph has drawn follows a palette
+ swap.
+
+ Args:
+ color: The color the line is drawn in.
+
+ Returns:
+ str: The tag of the theme carrying it.
"""
- shade = self._series_shade(layer)
- theme_tag = compose_tag(series_tag, SUF_GRAPH_THEME, shade)
- if not dpg.does_item_exist(theme_tag):
- with dpg.theme(tag=theme_tag), dpg.theme_component(dpg.mvLineSeries):
- dpg_add_palette_theme_color(
- dpg.mvPlotCol_Line,
- self._series_color(layer, shade),
- category=dpg.mvThemeCat_Plots,
- )
-
- dpg_bind_item_theme(series_tag, theme_tag)
+ if color in self._series_themes:
+ return self._series_themes[color]
+
+ theme_tag = compose_tag(self.tag, SUF_GRAPH_THEME, str(len(self._series_themes)))
+ with dpg.theme(tag=theme_tag), dpg.theme_component(dpg.mvLineSeries):
+ dpg_add_palette_theme_color(
+ dpg.mvPlotCol_Line,
+ color,
+ category=dpg.mvThemeCat_Plots,
+ )
+
+ self._series_themes[color] = theme_tag
+ return theme_tag
def _add_position_indicator(self) -> None:
dpg_delete_item(self.position_indicator_tag)
diff --git a/src/sampletones_application/ui/elements/layout/columns.py b/src/sampletones_application/ui/elements/layout/columns.py
index 010a57192..85ba26481 100644
--- a/src/sampletones_application/ui/elements/layout/columns.py
+++ b/src/sampletones_application/ui/elements/layout/columns.py
@@ -55,7 +55,7 @@ def build(
"""Builds the ground wrapper and the column row from ``columns``, then binds their themes.
Returns the number of fixed-width side columns — the ones that hold their width while the
- centre stretches — which the responsive width sizing shares the viewport's surplus among.
+ center stretches — which the responsive width sizing shares the viewport's surplus among.
"""
with dpg.child_window(
width=-1,
@@ -94,7 +94,7 @@ def row(
Where :meth:`build` frames a whole tab, ``row`` composes a side-by-side group inside a
column a coordinator already owns: it drops the ground wrapper and the outer gaps, so the
- columns sit flush to the container edges with a single gap between each neighbour. Each
+ columns sit flush to the container edges with a single gap between each neighbor. Each
column's builder fills its cell directly, letting the hosted cards own their own surface.
A ``height`` of ``0`` sizes the row to its content. A ``tag`` names the row table so a
coordinator can resize it when its hosted cards collapse.
@@ -149,7 +149,7 @@ def _declare_row_columns(
panel_gap: int,
columns: Sequence[ColumnSpec],
) -> None:
- """Declares each content column with a fixed gap column between neighbours only."""
+ """Declares each content column with a fixed gap column between neighbors only."""
for index, column in enumerate(columns):
if index > 0:
dpg.add_table_column(
diff --git a/src/sampletones_application/ui/elements/layout/responsive.py b/src/sampletones_application/ui/elements/layout/responsive.py
index 8caffb6f4..7956f995a 100644
--- a/src/sampletones_application/ui/elements/layout/responsive.py
+++ b/src/sampletones_application/ui/elements/layout/responsive.py
@@ -7,10 +7,10 @@ def expanded_side_width(
) -> int:
"""Widens a fixed side column as the viewport grows past the design baseline.
- A tab's centre column stretches while its side columns hold fixed widths, so the extra room a
- viewport wider than ``baseline_viewport_width`` offers is shared out with the centre taking
+ A tab's center column stretches while its side columns hold fixed widths, so the extra room a
+ viewport wider than ``baseline_viewport_width`` offers is shared out with the center taking
``center_weight`` shares against each side's single share. Splitting the surplus
- ``center_weight + side_panel_count`` ways and granting one share to each side keeps the centre the
+ ``center_weight + side_panel_count`` ways and granting one share to each side keeps the center the
widest column while the sides breathe on large displays. At the baseline the column sits at its
configured ``base_width`` and grows only as surplus appears above it.
"""
diff --git a/src/sampletones_application/ui/elements/layout/well.py b/src/sampletones_application/ui/elements/layout/well.py
new file mode 100644
index 000000000..c83d88c68
--- /dev/null
+++ b/src/sampletones_application/ui/elements/layout/well.py
@@ -0,0 +1,47 @@
+import dearpygui.dearpygui as dpg
+
+from sampletones_application.tags.compose import compose_tag
+from sampletones_application.tags.general import (
+ SUF_GROUP,
+ TAG_GLOBAL_THEME_PANEL_GROUND,
+)
+from sampletones_application.ui.themes.registry import ThemeRegistry
+
+
+def well(
+ parent: str,
+ tag: str,
+ *,
+ padding: int,
+ margin: int,
+ height: int = 0,
+ show: bool = True,
+) -> str:
+ """Sink a recessed region into a card and bind its depth theme.
+
+ A well sinks a list below the card it sits on, the way a column of cards sits below the
+ tab around it, so a run of rows reads as one body rather than as content loose on the
+ card. Alongside ``card()`` this is where the recessed depth theme is bound; the region
+ sizes itself to its rows unless ``height`` reserves a footprint.
+
+ Returns the inset body group content is added to, which keeps ``padding`` clear at the
+ sides. ``margin`` opens the gap above the first row and below the last, which the row
+ spacing between the content and the spacers adds to.
+ """
+ body_tag = compose_tag(tag, SUF_GROUP)
+ with dpg.child_window(
+ tag=tag,
+ parent=parent,
+ width=-1,
+ height=height,
+ auto_resize_y=height == 0,
+ border=False,
+ no_scrollbar=True,
+ show=show,
+ ):
+ dpg.add_spacer(height=margin)
+ dpg.add_group(tag=body_tag, indent=padding, width=-padding)
+ dpg.add_spacer(height=margin)
+
+ ThemeRegistry.get(TAG_GLOBAL_THEME_PANEL_GROUND).bind_to_item(tag)
+ return body_tag
diff --git a/src/sampletones_application/ui/elements/menu_section.py b/src/sampletones_application/ui/elements/menu_section.py
new file mode 100644
index 000000000..ee5de8e86
--- /dev/null
+++ b/src/sampletones_application/ui/elements/menu_section.py
@@ -0,0 +1,73 @@
+from typing import Optional, Tuple
+
+import dearpygui.dearpygui as dpg
+
+from sampletones_application.tags.compose import compose_tag
+from sampletones_application.tags.general import SUF_HANDLER_REGISTRY
+from sampletones_application.utils.gui.dpg import dpg_append_items, dpg_delete_item
+from sampletones_shared.types.application import Sender
+from sampletones_shared.types.callback import VoidCallback
+
+
+class MenuSection:
+ """A run of items a menu states afresh each time a reader opens it.
+
+ A menu bar is built once, while what its items should say follows the cursor and the selection
+ at the moment the menu is opened. A section keeps a marker inside the menu it belongs to: the
+ framework reports that marker drawn once a frame while the menu stands open, so a gap in those
+ reports names a fresh opening, and the section takes its standing items away and states them
+ again. The items stay between openings, which gives the popup its full height on the frame it
+ appears, and the rebuilt ones take over a frame later — long before an item can be reached.
+
+ The marker leads the menu, holding nothing: a container standing below a menu item takes the
+ width those items span as its own, which the popup then grows to fit on every frame it stays
+ open.
+ """
+
+ def __init__(
+ self,
+ *,
+ menu_tag: str,
+ marker_tag: str,
+ build: VoidCallback,
+ ) -> None:
+ self._menu_tag = menu_tag
+ self._marker_tag = marker_tag
+ self._build = build
+ self._handler_tag = compose_tag(marker_tag, SUF_HANDLER_REGISTRY)
+ self._drawn_frame: Optional[int] = None
+ self._items: Tuple[Sender, ...] = ()
+
+ def add_marker(self) -> None:
+ """States the marker the section is reported by, inside the menu being built."""
+ dpg.add_group(tag=self._marker_tag)
+
+ def watch(self) -> None:
+ """Reports the marker drawn from here on, and states the section once."""
+ with dpg.item_handler_registry(tag=self._handler_tag):
+ dpg.add_item_visible_handler(callback=self._on_marker_drawn)
+
+ dpg.bind_item_handler_registry(self._marker_tag, self._handler_tag)
+ self.refresh()
+
+ def refresh(self) -> None:
+ """Takes the standing items away and asks for the section as it reads now.
+
+ Only what the last build stated is taken away, so the items the menu declares for itself
+ stand where they are.
+ """
+ for item in self._items:
+ dpg_delete_item(item)
+
+ self._items = dpg_append_items(self._menu_tag, self._build)
+
+ def _on_marker_drawn(
+ self,
+ _sender: Sender,
+ _app_data: Sender,
+ ) -> None:
+ frame = dpg.get_frame_count()
+ reopened = self._drawn_frame is None or frame - self._drawn_frame > 1
+ self._drawn_frame = frame
+ if reopened:
+ self.refresh()
diff --git a/src/sampletones_application/ui/elements/path.py b/src/sampletones_application/ui/elements/path.py
index 0c394378d..b76ca1410 100644
--- a/src/sampletones_application/ui/elements/path.py
+++ b/src/sampletones_application/ui/elements/path.py
@@ -8,6 +8,7 @@
SUF_GROUP,
SUF_HANDLER_REGISTRY,
SUF_LABEL,
+ SUF_TOOLTIP,
)
from sampletones_application.ui.elements.fonts.font import Font
from sampletones_application.ui.elements.fonts.registry import FontRegistry
@@ -66,6 +67,7 @@ def __init__(
self.label_tag = compose_tag(tag, SUF_LABEL)
self.handler_tag = compose_tag(tag, SUF_HANDLER_REGISTRY)
self.group_tag = compose_tag(tag, SUF_GROUP)
+ self.tooltip_tag = compose_tag(tag, SUF_TOOLTIP)
self._create_text()
self._create_handler()
@@ -87,7 +89,7 @@ def _create_text(self) -> None:
FontRegistry.bind_to_item(self.label_tag, self.font)
FontRegistry.bind_to_item(self.tag, self.font)
- self.tooltip = show_tooltip(self.tag, self.path_text)
+ self.tooltip = show_tooltip(self.tag, self.path_text, tag=self.tooltip_tag)
@property
def path_text(self) -> str:
@@ -144,9 +146,9 @@ def set_path(self, path: Pathlike, shorten: bool = True) -> None:
dpg.set_value(self.tooltip, self.path_text)
def set_status(self, text: str, color: BaseColor) -> None:
- """Displays a non-path status (missing or not applicable) in a muted colour.
+ """Displays a non-path status (missing or not applicable) in a muted color.
- The path is cleared so the row is inert: hovering holds the muted colour and a
+ The path is cleared so the row is inert: hovering holds the muted color and a
click has nothing to open.
"""
self.path = Path()
@@ -162,8 +164,15 @@ def get_path(self) -> Path:
return self.path
def destroy(self) -> None:
+ """Takes the whole widget away, the hover explanation among it.
+
+ DearPyGui keeps a tooltip beside the item it explains rather than inside it, so a
+ tooltip outliving its path would go on triggering off whatever moved into that place.
+ """
dpg_delete_item(self.handler_tag)
+ dpg_delete_item(self.tooltip_tag)
dpg_delete_item(self.tag)
+ dpg_delete_item(self.group_tag)
class GUIDestinationPathText(GUIPathText):
diff --git a/src/sampletones_application/ui/elements/pitch_stepper.py b/src/sampletones_application/ui/elements/pitch_stepper.py
index a275fe303..a5901260f 100644
--- a/src/sampletones_application/ui/elements/pitch_stepper.py
+++ b/src/sampletones_application/ui/elements/pitch_stepper.py
@@ -23,7 +23,7 @@
from sampletones_application.ui.elements.status import GUIStatusBar
from sampletones_application.ui.themes.registry import ThemeRegistry
from sampletones_application.utils.callbacks.queue import CallbackQueue
-from sampletones_application.utils.gui.dpg import dpg_delete_item, dpg_set_value
+from sampletones_application.utils.gui.dpg import dpg_configure_item, dpg_delete_item, dpg_set_value
from sampletones_application.utils.gui.palette.dpg import dpg_set_palette_color
from sampletones_application.utils.gui.tooltip import show_tooltip
from sampletones_application.utils.palette.colors.base import BaseColor
@@ -36,7 +36,7 @@ class PitchStepperStyle:
"""The styling a pitch stepper draws itself with, narrowed from the general layout.
A stepper needs only its own dimensions, the plus/minus button dimensions it embeds, and
- the colour of its read-only value readout. Assembling this at the composition root lets a
+ the color of its read-only value readout. Assembling this at the composition root lets a
panel that builds steppers receive just these three fields, mirroring the
:meth:`TreeColors.create` narrowing.
"""
@@ -117,6 +117,10 @@ def set_value(self, value: int) -> None:
self._value = self._kind.clamp(value)
self._render()
+ def set_shown(self, shown: bool) -> None:
+ """Shows or hides the stepper, label and buttons together, which is the whole of it."""
+ dpg_configure_item(self._table_tag, show=shown)
+
def _build(self) -> None:
self._clear_existing_items()
with dpg.table(
diff --git a/src/sampletones_application/ui/elements/plus_minus_buttons.py b/src/sampletones_application/ui/elements/plus_minus_buttons.py
index 73dc9edc5..b355f4363 100644
--- a/src/sampletones_application/ui/elements/plus_minus_buttons.py
+++ b/src/sampletones_application/ui/elements/plus_minus_buttons.py
@@ -41,7 +41,7 @@ class GUIPlusMinusButtons(CallbackMixin):
sign occupies. With ``hold_repeat`` a held button repeats its press after an initial
delay, matching the stepping feel of a numeric field; otherwise each button fires once
per click. Either button can be enabled or disabled independently, so a control can
- grey out a step that would have no effect.
+ gray out a step that would have no effect.
"""
def __init__(
diff --git a/tests/unit/sampletones_core/reconstructions/reconstructor/selector/__init__.py b/src/sampletones_application/ui/elements/stems/__init__.py
similarity index 100%
rename from tests/unit/sampletones_core/reconstructions/reconstructor/selector/__init__.py
rename to src/sampletones_application/ui/elements/stems/__init__.py
diff --git a/src/sampletones_application/ui/elements/stems/list.py b/src/sampletones_application/ui/elements/stems/list.py
new file mode 100644
index 000000000..16acae045
--- /dev/null
+++ b/src/sampletones_application/ui/elements/stems/list.py
@@ -0,0 +1,567 @@
+from typing import Any, Callable, Dict, FrozenSet, Optional, Sequence, Tuple
+
+import dearpygui.dearpygui as dpg
+
+from sampletones_application.categories.context import channel_label
+from sampletones_application.categories.manager import LanguageManager
+from sampletones_application.layout.general.stems import StemsListLayout
+from sampletones_application.tags.compose import compose_tag
+from sampletones_application.tags.general import (
+ SUF_BUTTON,
+ SUF_CHANNELS,
+ SUF_CHECKBOX,
+ SUF_GROUP,
+ SUF_HANDLER_REGISTRY,
+ SUF_LEVEL,
+ SUF_PAYLOAD,
+ SUF_ROW,
+ SUF_STRIP,
+ SUF_TABLE,
+ SUF_TEXT,
+ SUF_TOOLTIP,
+ SUF_WELL,
+ TAG_GLOBAL_THEME_CHANNEL_MUTED,
+ TAG_GLOBAL_THEME_DANGER_BUTTON,
+ TAG_GLOBAL_THEME_STEMS_DROP_STRIP,
+ TAG_GLOBAL_THEME_STEMS_ROW,
+ TAG_GLOBAL_THEME_STEMS_ROW_INERT,
+)
+from sampletones_application.ui.elements.fonts.font import Font
+from sampletones_application.ui.elements.fonts.registry import FontRegistry
+from sampletones_application.ui.elements.layout.well import well
+from sampletones_application.ui.elements.status import GUIStatusBar
+from sampletones_application.ui.themes.channels import CHANNEL_THEME_TAGS
+from sampletones_application.ui.themes.registry import ThemeRegistry
+from sampletones_application.utils.gui.dpg import (
+ dpg_configure_item,
+ dpg_delete_item,
+ dpg_set_value,
+)
+from sampletones_application.utils.gui.tooltip import show_tooltip
+from sampletones_application.view_model.shared.stems import (
+ StemRowViewModel,
+ StemsListViewModel,
+)
+from sampletones_core.constants.enums import ChannelName
+from sampletones_shared.types.application import Sender
+from sampletones_shared.types.callback import MessageCallback, StringCallback
+from sampletones_shared.utils.callbacks import CallbackMixin
+
+RowShape = Tuple[Tuple[str, ...], bool, Tuple[Tuple[str, int, Tuple[str, ...]], ...]]
+
+ChannelsCallback = Callable[[str, FrozenSet[ChannelName]], None]
+KeyOffsetCallback = Callable[[str, int], None]
+KeyPairCallback = Callable[[str, str], None]
+
+
+class GUIStemsList(CallbackMixin):
+ """The stems of one setup, as a table of rows banded by the levels they pick on.
+
+ Both the converter's gathered recordings and a reconstruction's recorded assignment are the
+ same list, so one definition draws them and each owner turns on the affordances it can
+ honor: ``draggable`` makes a row itself the thing you drag and opens a drop strip between
+ the bands, ``master_checkbox`` gives the row a leading box moving every channel at once,
+ ``removable`` gives it the danger-toned button that takes it out, and ``retain_last_row``
+ holds that button back once one row is all that stands. Rows are keyed by the
+ identity their owner reports gestures under, and every column lines up across the bands
+ because each table holds one fixed column per channel in play.
+ """
+
+ def __init__(
+ self,
+ *,
+ prefix: str,
+ layout: StemsListLayout,
+ language_manager: LanguageManager,
+ status_bar: GUIStatusBar,
+ draggable: bool,
+ removable: bool,
+ retain_last_row: bool,
+ master_checkbox: bool,
+ ) -> None:
+ self._prefix = prefix
+ self._layout = layout
+ self._language_manager = language_manager
+ self._status_bar = status_bar
+ self._draggable = draggable
+ self._removable = removable
+ self._retain_last_row = retain_last_row
+ self._master_checkbox = master_checkbox
+
+ self._level_template = language_manager["global.stems.template.level_caption"]
+ self._lbl_remove = language_manager["global.stems.label.remove"]
+ self._msg_drag = language_manager["global.stems.message.drag_tooltip"]
+ self._msg_inert = language_manager["global.stems.message.inert_tooltip"]
+ self._msg_missing = language_manager["global.stems.message.missing_tooltip"]
+ self._msg_unoffered = language_manager["global.stems.message.unoffered_tooltip"]
+
+ self._payload = compose_tag(prefix, SUF_PAYLOAD)
+ self._well_tag = compose_tag(prefix, SUF_WELL)
+ self._body_tag = compose_tag(self._well_tag, SUF_GROUP)
+ self._table_tag = compose_tag(prefix, SUF_TABLE)
+ self._name_handler_tag = compose_tag(prefix, SUF_TEXT, SUF_HANDLER_REGISTRY)
+ self._channel_handler_tag = compose_tag(prefix, SUF_CHANNELS, SUF_HANDLER_REGISTRY)
+ self._master_handler_tag = compose_tag(prefix, SUF_CHECKBOX, SUF_HANDLER_REGISTRY)
+ self._button_handler_tag = compose_tag(prefix, SUF_BUTTON, SUF_HANDLER_REGISTRY)
+
+ self._rows: Dict[str, StemRowViewModel] = {}
+ self._channels_in_play: Tuple[ChannelName, ...] = ()
+ self._muted_channels: FrozenSet[ChannelName] = frozenset()
+ self._shape: RowShape = ((), False, ())
+ self._live = True
+
+ self.on_channels_changed: Optional[ChannelsCallback] = None
+ self.on_remove_requested: Optional[StringCallback] = None
+ self.on_menu_requested: Optional[StringCallback] = None
+ self.on_row_activated: Optional[StringCallback] = None
+ self.on_dropped_on_row: Optional[KeyPairCallback] = None
+ self.on_dropped_on_level: Optional[KeyOffsetCallback] = None
+
+ @property
+ def _handler_tags(self) -> Tuple[str, ...]:
+ """The registries the rows bind to, one per widget kind the list draws."""
+ return (
+ self._name_handler_tag,
+ self._channel_handler_tag,
+ self._master_handler_tag,
+ self._button_handler_tag,
+ )
+
+ @property
+ def tag(self) -> str:
+ """The recessed region the list is drawn in, which is what an owner shows and hides."""
+ return self._well_tag
+
+ @property
+ def activatable(self) -> bool:
+ """The owner answers a click on a row, so the list hands one on rather than absorbing it."""
+ return self.on_row_activated is not None
+
+ @property
+ def table_tag(self) -> str:
+ """The one table every row stands in while the levels are collapsed."""
+ return self._table_tag
+
+ def create(self, parent: str, *, show: bool = True) -> None:
+ """Build the list's recessed region and the handlers its rows share."""
+ self._create_handlers()
+ well(
+ parent,
+ self._well_tag,
+ padding=self._layout.well_padding,
+ margin=self._layout.well_margin,
+ show=show,
+ )
+
+ def update_view(self, view_model: StemsListViewModel) -> None:
+ self._rows = {row.key: row for row in view_model.rows}
+ self._channels_in_play = view_model.channels_in_play
+ self._muted_channels = view_model.muted_channels
+ self._live = view_model.live
+ self._sync_rows(view_model)
+ for row in view_model.rows:
+ self._render_row(row)
+
+ def row(self, key: str) -> Optional[StemRowViewModel]:
+ """The row a gesture named, as the list last rendered it."""
+ return self._rows.get(key)
+
+ def _create_handlers(self) -> None:
+ """Register one handler registry per row-widget kind.
+
+ A row's widgets carry their key as user data and share a registry with their kind, so
+ the hover explanation and the right-click both read the row they landed on rather than
+ needing a handler of their own.
+ """
+ for handler_tag in self._handler_tags:
+ dpg_delete_item(handler_tag)
+
+ with dpg.item_handler_registry(tag=self._name_handler_tag):
+ dpg.add_item_clicked_handler(callback=self._on_name_clicked)
+ dpg.add_item_hover_handler(callback=self._hover_callback(self._name_message))
+
+ with dpg.item_handler_registry(tag=self._channel_handler_tag):
+ dpg.add_item_hover_handler(callback=self._hover_callback(self._channel_message))
+
+ with dpg.item_handler_registry(tag=self._master_handler_tag):
+ dpg.add_item_hover_handler(callback=self._hover_callback(self._master_message))
+
+ with dpg.item_handler_registry(tag=self._button_handler_tag):
+ dpg.add_item_hover_handler(callback=self._hover_callback(self._remove_message))
+
+ def _hover_callback(self, message_function: MessageCallback) -> Callable[[Sender, int], None]:
+ """Route a hovered row widget's explanation to the status bar.
+
+ An item hover handler names the hovered item, whose user data is the row it belongs to,
+ so one callback per widget kind explains every row of that kind. The hover is reported a
+ frame after it happened, by which time a rebuilt list may have taken the widget away, so
+ the callback answers for the widgets still standing.
+ """
+
+ def hover_callback(_sender: Sender, app_data: int) -> None:
+ if not dpg.does_item_exist(app_data):
+ return
+
+ self._status_bar.set(message_function, user_data=dpg.get_item_user_data(app_data))
+
+ return hover_callback
+
+ def _sync_rows(self, view_model: StemsListViewModel) -> None:
+ """Rebuild the bands when the recordings, their levels or their boxes change.
+
+ Collapsing the levels draws every recording in one table, which is the shape a list
+ takes where the bands are a record of the setup rather than somewhere to drop onto.
+ """
+ shape = self._row_shape(view_model)
+ if shape == self._shape:
+ return
+
+ self._shape = shape
+ dpg.delete_item(self._body_tag, children_only=True)
+ if view_model.collapse_levels:
+ self._create_table(self._table_tag, view_model, view_model.rows)
+ return
+
+ for level_index in range(view_model.level_count):
+ self._create_level_strip(level_index)
+ self._create_level_caption(level_index)
+ self._create_level_table(level_index, view_model)
+
+ if view_model.level_count:
+ self._create_level_strip(view_model.level_count)
+
+ @staticmethod
+ def _row_shape(view_model: StemsListViewModel) -> RowShape:
+ """What the bands are built from: the columns, the banding, and where each row stands.
+
+ Which channels a row holds is drawn onto the widgets already standing, so a tick keeps
+ the bands as they are and the pointer keeps whatever it was over.
+ """
+ return (
+ tuple(str(channel_name) for channel_name in view_model.channels_in_play),
+ view_model.collapse_levels,
+ tuple(
+ (
+ row.key,
+ row.level,
+ tuple(sorted(str(channel_name) for channel_name in row.offered_channels)),
+ )
+ for row in view_model.rows
+ ),
+ )
+
+ def _create_level_strip(self, position: int) -> None:
+ """The gap a level is broken at: a recording dropped here takes a level of its own.
+
+ The strip reads as the gap it is and lights up only while a payload hovers it, so a
+ band separator stays a separator to everything but a drag.
+ """
+ if not self._draggable:
+ return
+
+ strip = dpg.add_button(
+ label="",
+ tag=self.level_tag(position, SUF_STRIP),
+ parent=self._body_tag,
+ height=self._layout.level_strip_height,
+ user_data=position,
+ payload_type=self._payload,
+ drop_callback=self._on_dropped_on_level,
+ )
+ ThemeRegistry.get(TAG_GLOBAL_THEME_STEMS_DROP_STRIP).bind_to_item(strip)
+
+ def _create_level_caption(self, level_index: int) -> None:
+ caption = dpg.add_text(
+ self._level_template.format(level_index + 1).upper(),
+ tag=self.level_tag(level_index, SUF_TEXT),
+ parent=self._body_tag,
+ )
+ FontRegistry.bind_to_item(caption, Font.MONO_SMALL)
+
+ def _create_level_table(self, level_index: int, view_model: StemsListViewModel) -> None:
+ self._create_table(
+ self.level_tag(level_index, SUF_TABLE),
+ view_model,
+ [row for row in view_model.rows if row.level == level_index],
+ )
+
+ def _create_table(
+ self,
+ tag: str,
+ view_model: StemsListViewModel,
+ rows: Sequence[StemRowViewModel],
+ ) -> None:
+ """One grid of rows: the master box, the name, a column per channel in play, the button."""
+ with dpg.table(
+ tag=tag,
+ parent=self._body_tag,
+ header_row=False,
+ policy=dpg.mvTable_SizingFixedFit,
+ resizable=False,
+ ):
+ if self._master_checkbox:
+ dpg.add_table_column(width_fixed=True, init_width_or_weight=self._layout.master_column_width)
+
+ dpg.add_table_column(width_stretch=True)
+ for _channel_name in view_model.channels_in_play:
+ dpg.add_table_column(width_fixed=True, init_width_or_weight=self._layout.channel_column_width)
+
+ if self._removable:
+ dpg.add_table_column(width_fixed=True, init_width_or_weight=self._layout.remove_button_width)
+
+ for row in rows:
+ self._create_row(row, view_model)
+
+ def _create_row(self, row: StemRowViewModel, view_model: StemsListViewModel) -> None:
+ with dpg.table_row(tag=self.row_tag(row.key, SUF_GROUP)):
+ if self._master_checkbox:
+ self._create_master(row)
+
+ self._create_name(row)
+ for channel_name in view_model.channels_in_play:
+ self._create_channel(row, channel_name)
+
+ if self._removable:
+ self._create_remove(row)
+
+ def _create_master(self, row: StemRowViewModel) -> None:
+ """The box moving every channel the row offers at once."""
+ master = dpg.add_checkbox(
+ tag=self.row_tag(row.key, SUF_CHECKBOX),
+ default_value=row.takes_part,
+ user_data=row.key,
+ callback=self._on_master_changed,
+ )
+ dpg.bind_item_handler_registry(master, self._master_handler_tag)
+
+ def _create_name(self, row: StemRowViewModel) -> None:
+ """The row itself: what names the recording, what you drag it by, and what you drop onto."""
+ name = dpg.add_selectable(
+ label=row.name,
+ tag=self.row_tag(row.key, SUF_TEXT),
+ user_data=row.key,
+ callback=self._on_name_clicked_off,
+ payload_type=self._payload,
+ drop_callback=self._on_dropped_on_row,
+ )
+ if self._draggable:
+ with dpg.drag_payload(parent=name, drag_data=row.key, payload_type=self._payload):
+ dpg.add_text(row.name)
+
+ FontRegistry.bind_to_item(name, Font.REGULAR_SMALL)
+ dpg.bind_item_handler_registry(name, self._name_handler_tag)
+ show_tooltip(name, self._row_explanation(row), text_tag=self.row_tag(row.key, SUF_TOOLTIP))
+
+ def _create_channel(self, row: StemRowViewModel, channel_name: ChannelName) -> None:
+ """The box giving the recording a channel, where the recording holds frames on it.
+
+ A recording holding none on this channel leaves the cell open, so the columns keep
+ lining up across the rows while only a reachable choice is drawn.
+ """
+ if channel_name not in row.offered_channels:
+ dpg.add_spacer()
+ return
+
+ checkbox_tag = self.channel_tag(row.key, channel_name)
+ dpg.add_checkbox(
+ label=channel_label(self._language_manager, channel_name),
+ tag=checkbox_tag,
+ default_value=channel_name in row.channels,
+ user_data=(row.key, channel_name),
+ callback=self._on_channels_changed,
+ )
+ dpg.bind_item_handler_registry(checkbox_tag, self._channel_handler_tag)
+
+ def _create_remove(self, row: StemRowViewModel) -> None:
+ remove = dpg.add_button(
+ label=self._lbl_remove,
+ tag=self.row_tag(row.key, SUF_BUTTON),
+ width=self._layout.remove_button_width,
+ user_data=row.key,
+ callback=self._on_remove_requested,
+ )
+ FontRegistry.bind_to_item(remove, Font.MONO_SMALL)
+ ThemeRegistry.get(TAG_GLOBAL_THEME_DANGER_BUTTON).bind_to_item(remove)
+ dpg.bind_item_handler_registry(remove, self._button_handler_tag)
+
+ def _render_row(self, row: StemRowViewModel) -> None:
+ """Draw what the row currently holds onto the widgets it already stands as.
+
+ A row contributing nothing grays through its theme rather than through ``enabled``, so
+ it answers a drag and a right-click as readily as one in play. A box on a channel
+ switched off elsewhere takes the muted tone and stays as clickable as any other.
+ """
+ for channel_name in self._row_boxes(row):
+ tag = self.channel_tag(row.key, channel_name)
+ dpg_configure_item(tag, enabled=self._live)
+ dpg_set_value(tag, channel_name in row.channels)
+ ThemeRegistry.get(self._channel_theme(channel_name)).bind_to_item(tag)
+
+ name_tag = self.row_tag(row.key, SUF_TEXT)
+ dpg_set_value(name_tag, False)
+ dpg_configure_item(name_tag, enabled=self._live)
+ dpg_set_value(self.row_tag(row.key, SUF_TOOLTIP), self._row_explanation(row))
+ row_theme = TAG_GLOBAL_THEME_STEMS_ROW if row.in_play else TAG_GLOBAL_THEME_STEMS_ROW_INERT
+ ThemeRegistry.get(row_theme).bind_to_item(name_tag)
+ if self._master_checkbox:
+ master_tag = self.row_tag(row.key, SUF_CHECKBOX)
+ dpg_configure_item(master_tag, enabled=self._live and row.offers_channels)
+ dpg_set_value(master_tag, row.takes_part)
+
+ if self._removable:
+ dpg_configure_item(self.row_tag(row.key, SUF_BUTTON), enabled=self._live and self._releasable)
+
+ @property
+ def _releasable(self) -> bool:
+ """Whether a row may leave, which a list holding on to its last one answers by its count."""
+ return len(self._rows) > 1 or not self._retain_last_row
+
+ def _row_boxes(self, row: StemRowViewModel) -> Tuple[ChannelName, ...]:
+ """The channels the row actually draws a box for, in the order the columns stand."""
+ return tuple(channel_name for channel_name in self._channels_in_play if channel_name in row.offered_channels)
+
+ def _channel_theme(self, channel_name: ChannelName) -> str:
+ """The tone a channel's boxes take: its own color, muted where the channel is off."""
+ if channel_name in self._muted_channels:
+ return TAG_GLOBAL_THEME_CHANNEL_MUTED
+
+ return CHANNEL_THEME_TAGS[channel_name]
+
+ def _row_explanation(self, row: StemRowViewModel) -> str:
+ """What the row's hover states: where the recording is, why it is grayed out where it
+ contributes nothing, and how it moves where the list lets it."""
+ lines = [str(row.path)]
+ if not row.available:
+ lines.append(self._msg_missing)
+ elif not row.offers_channels:
+ lines.append(self._msg_unoffered)
+ elif not row.takes_part:
+ lines.append(self._msg_inert)
+
+ if self._draggable:
+ lines.append(self._msg_drag)
+
+ return "\n".join(lines)
+
+ def _name_message(self, *_args: Any, user_data: str, **_kwargs: Any) -> str:
+ row = self._rows.get(user_data)
+ if row is None:
+ return ""
+
+ if self._draggable:
+ return self._language_manager["global.stems.message.status_row_drag"].format(name=row.name)
+
+ if self.activatable:
+ return self._language_manager["global.stems.message.status_row_reveal"].format(name=row.name)
+
+ return row.name
+
+ def _channel_message(
+ self,
+ *_args: Any,
+ user_data: Tuple[str, ChannelName],
+ **_kwargs: Any,
+ ) -> str:
+ key, channel_name = user_data
+ row = self._rows.get(key)
+ if row is None:
+ return ""
+
+ channel = channel_label(self._language_manager, channel_name)
+ if channel_name in self._muted_channels:
+ return self._language_manager["global.stems.message.status_channel_muted"].format(
+ channel=channel,
+ name=row.name,
+ )
+
+ return self._language_manager["global.stems.message.status_channel"].format(
+ channel=channel,
+ name=row.name,
+ )
+
+ def _master_message(self, *_args: Any, user_data: str, **_kwargs: Any) -> str:
+ row = self._rows.get(user_data)
+ if row is None:
+ return ""
+
+ return self._language_manager["global.stems.message.status_master"].format(name=row.name)
+
+ def _remove_message(self, *_args: Any, user_data: str, **_kwargs: Any) -> str:
+ row = self._rows.get(user_data)
+ if row is None:
+ return ""
+
+ return self._language_manager["global.stems.message.status_remove"].format(name=row.name)
+
+ def _on_channels_changed(
+ self,
+ _sender: Sender,
+ _value: bool,
+ user_data: Tuple[str, ChannelName],
+ ) -> None:
+ key, _channel_name = user_data
+ row = self._rows.get(key)
+ if row is None:
+ return
+
+ channels = frozenset(
+ channel_name for channel_name in self._row_boxes(row) if dpg.get_value(self.channel_tag(key, channel_name))
+ )
+ self.call(self.on_channels_changed, key, channels)
+
+ def _on_master_changed(self, _sender: Sender, value: bool, user_data: str) -> None:
+ """The master box hands the row every channel it offers, or takes them all away."""
+ row = self._rows.get(user_data)
+ if row is None:
+ return
+
+ channels = frozenset(self._row_boxes(row)) if value else frozenset()
+ self.call(self.on_channels_changed, user_data, channels)
+
+ def _on_remove_requested(self, _sender: Sender, _app_data: Any, user_data: str) -> None:
+ self.call(self.on_remove_requested, user_data)
+
+ def _on_name_clicked_off(self, sender: Sender, _value: bool, user_data: str) -> None:
+ """Let go of a clicked row and hand it on: the list names recordings, it selects none."""
+ dpg_set_value(sender, False)
+ if self.activatable:
+ self.call(self.on_row_activated, user_data)
+
+ def _on_name_clicked(self, _sender: Sender, app_data: Tuple[int, int]) -> None:
+ mouse_button, clicked_item = app_data
+ if mouse_button != dpg.mvMouseButton_Right:
+ return
+
+ key = dpg.get_item_user_data(clicked_item)
+ if isinstance(key, str):
+ self.call(self.on_menu_requested, key)
+
+ def _on_dropped_on_row(self, sender: Sender, app_data: str) -> None:
+ """A recording was dropped on a row, so it joins that row's level at its place."""
+ target = dpg.get_item_user_data(sender)
+ if isinstance(target, str):
+ self.call(self.on_dropped_on_row, app_data, target)
+
+ def _on_dropped_on_level(self, sender: Sender, app_data: str) -> None:
+ """A recording was dropped in a gap, so it takes a level of its own there."""
+ position = dpg.get_item_user_data(sender)
+ if isinstance(position, int):
+ self.call(self.on_dropped_on_level, app_data, position)
+
+ def row_tag(self, key: str, suffix: str) -> str:
+ """The tag one of a row's widgets carries, which is how anything outside addresses it."""
+ return compose_tag(self._prefix, SUF_ROW, key, suffix)
+
+ def level_tag(self, level_index: int, suffix: str) -> str:
+ """The tag one of a band's widgets carries: its caption, its table, or the strip above it."""
+ return compose_tag(self._prefix, SUF_LEVEL, str(level_index), suffix)
+
+ def channel_tag(self, key: str, channel_name: ChannelName) -> str:
+ """The tag the box giving ``key`` a channel carries."""
+ return compose_tag(
+ self._prefix,
+ SUF_ROW,
+ key,
+ SUF_CHANNELS,
+ compose_tag(channel_name, SUF_CHECKBOX),
+ )
diff --git a/src/sampletones_application/ui/elements/table/caret.py b/src/sampletones_application/ui/elements/table/caret.py
index 40f0a0166..a7a4d1bd7 100644
--- a/src/sampletones_application/ui/elements/table/caret.py
+++ b/src/sampletones_application/ui/elements/table/caret.py
@@ -19,7 +19,7 @@ def _as_item_id(item: Sender) -> Sender:
"""Resolves an alias string to its numeric item id, passing ids through unchanged.
``get_active_window`` and ``get_item_parent`` report items by alias while stored tags
- are also aliases, so both sides are normalised to ids before comparison.
+ are also aliases, so both sides are normalized to ids before comparison.
"""
if isinstance(item, str):
return int(dpg.get_alias_id(item))
diff --git a/src/sampletones_application/ui/elements/table/selection.py b/src/sampletones_application/ui/elements/table/selection.py
index 14729cabf..24d2cd9fc 100644
--- a/src/sampletones_application/ui/elements/table/selection.py
+++ b/src/sampletones_application/ui/elements/table/selection.py
@@ -15,7 +15,7 @@ class TableSelection(Generic[KeyT]):
A grid states which of its cells the selection covers, in whatever coordinates it selects in;
which of them stand painted, and how far a held pointer has carried, are held here. A selected
- cell is drawn by the selectable's own selected state, which the table's theme colours, so a
+ cell is drawn by the selectable's own selected state, which the table's theme colors, so a
repaint reaches only the cells whose membership changed.
"""
diff --git a/src/sampletones_application/ui/elements/tree/browser.py b/src/sampletones_application/ui/elements/tree/browser.py
index 2428bd6eb..e64075b77 100644
--- a/src/sampletones_application/ui/elements/tree/browser.py
+++ b/src/sampletones_application/ui/elements/tree/browser.py
@@ -104,7 +104,7 @@ def refresh_status_message(self) -> str: ...
def create_panel(self, parent: str) -> None:
"""Builds the card, and fills the tree where the panel is the one reading its model.
- A browser reading the filesystem shows its rows as it appears, while a catalogue filled by
+ A browser reading the filesystem shows its rows as it appears, while a catalog filled by
the owner that gathers it waits for that reading to arrive.
"""
self._setup_handlers()
@@ -204,7 +204,7 @@ def _create_tree_root(self) -> None:
pass
def _create_tree_root_heading(self, label: str) -> None:
- """Opens the root container as a labelled row the whole tree folds under."""
+ """Opens the root container as a labeled row the whole tree folds under."""
with dpg.tree_node(
label=label,
tag=self.tree_tag,
diff --git a/src/sampletones_application/ui/elements/tree/tree.py b/src/sampletones_application/ui/elements/tree/tree.py
index 6a3c48f04..ceb332a12 100644
--- a/src/sampletones_application/ui/elements/tree/tree.py
+++ b/src/sampletones_application/ui/elements/tree/tree.py
@@ -49,6 +49,7 @@
from sampletones_application.ui.elements.button import GUIButton
from sampletones_application.ui.elements.context_menu import (
add_detail_items,
+ add_path_menu_items,
add_play_menu_item,
)
from sampletones_application.ui.elements.fonts.font import Font
@@ -79,11 +80,11 @@
)
from sampletones_application.utils.palette.colors.base import BaseColor
from sampletones_application.utils.parallelization.thread import (
- BackgroundWorkCancelled,
+ BackgroundWorkCanceled,
SingleThreadExecutor,
)
from sampletones_core.configs.display import (
- format_generators,
+ format_channels,
format_nes_frequency,
format_sample_rate,
format_spectrum_method,
@@ -110,7 +111,6 @@
PathCallback,
VoidCallback,
)
-from sampletones_shared.utils.system.paths import open_path_in_explorer
NO_EXPANDED_ROWS: Final[FrozenSet[str]] = frozenset()
@@ -173,7 +173,7 @@ def __init__(
self._lbl_detail_spectrum_method = language_manager["global.context.label.detail_spectrum_method"]
self._lbl_detail_transformation_gamma = language_manager["global.context.label.detail_transformation_gamma"]
self._lbl_detail_window_size = language_manager["global.context.label.detail_window_size"]
- self._lbl_detail_generators = language_manager["global.context.label.detail_generators"]
+ self._lbl_detail_channels = language_manager["global.context.label.detail_channels"]
self._lbl_detail_configuration = language_manager["global.context.label.detail_configuration"]
self.on_favorites_filter_changed: Optional[Callable[[str, bool], None]] = None
@@ -295,8 +295,8 @@ def create_favorites_filter(self, parent: str) -> None:
"""Builds the control showing the favorites alone, as a row of its own under the search box.
The checkbox carries the label, so the words are part of what the reader clicks, and the star
- beside it reads in the colour the mode it stands for is drawn in. The label reads in the pair
- every checkbox reads — the text colour while the control is live, the muted one while a
+ beside it reads in the color the mode it stands for is drawn in. The label reads in the pair
+ every checkbox reads — the text color while the control is live, the muted one while a
rebuild holds it — so the shade states whether the control can be acted on.
"""
self._favorites_checkbox_tag = compose_tag(self.tag, SUF_CHECKBOX_FAVORITES)
@@ -341,14 +341,14 @@ def _on_favorites_only_changed(
self.redraw_tree()
def _apply_favorites_glyph_color(self) -> None:
- """Colours the star by the mode the control reads, wherever the browser offers one."""
+ """Colors the star by the mode the control reads, wherever the browser offers one."""
if self._favorites_glyph_tag is None:
return
dpg_set_palette_color(self._favorites_glyph_tag, self._favorites_glyph_color())
def _favorites_glyph_color(self) -> BaseColor:
- """The colour the star takes: the favorite colour while the mode is on, muted while it is off."""
+ """The color the star takes: the favorite color while the mode is on, muted while it is off."""
if self._filter.favorites_only:
return self._colors.favorite
@@ -414,7 +414,7 @@ def _append_spec(
decision covers the whole subtree and the traversal walks on.
"""
if SingleThreadExecutor.is_shutting_down():
- raise BackgroundWorkCancelled
+ raise BackgroundWorkCanceled
if not self._is_node_drawn(node):
return
@@ -466,7 +466,7 @@ def _finish_emit(
The emitter runs this once its last batch has attached. A filtered rebuild that drew no
row fills the cleared tree with the message naming that outcome, so the filter's answer is
- legible where the rows would be. Applying the filter here lets late-emitted nodes honour
+ legible where the rows would be. Applying the filter here lets late-emitted nodes honor
an active search, and releasing the lock hands control back to interactive rebuilds.
"""
if root_tag == self.tree_tag and self._filter.is_active and not drawn_rows:
@@ -811,7 +811,7 @@ def _reconstruction_detail_items(
(self._lbl_detail_nes_frequency, format_nes_frequency(fields.nf)),
(self._lbl_detail_spectrum_method, format_spectrum_method(fields.sm)),
(self._lbl_detail_transformation_gamma, str(fields.tg)),
- (self._lbl_detail_generators, format_generators(fields.generators)),
+ (self._lbl_detail_channels, format_channels(fields.channels)),
(self._lbl_detail_configuration, short_hash(fields.ch)),
]
@@ -832,19 +832,7 @@ def _add_context_menu_play_item(self, node: FileSystemNode) -> None:
)
def _add_context_menu_path_items(self, path: Path) -> None:
- dpg.add_separator()
- dpg.add_menu_item(
- label=self._language_manager["global.context.label.copy_filename"],
- callback=lambda: dpg.set_clipboard_text(str(path.name)),
- )
- dpg.add_menu_item(
- label=self._language_manager["global.context.label.copy_path"],
- callback=lambda: dpg.set_clipboard_text(str(path)),
- )
- dpg.add_menu_item(
- label=self._language_manager["global.context.label.open_in_explorer"],
- callback=lambda: open_path_in_explorer(path),
- )
+ add_path_menu_items(self._language_manager, path)
def _add_context_menu_sequencer_items(self, node: FileSystemNode) -> None:
"""Add the send-to-sequencer item, live while its host reports the sequencer accepts one."""
diff --git a/src/sampletones_application/ui/elements/window.py b/src/sampletones_application/ui/elements/window.py
index 4a832b97e..bf7535e31 100644
--- a/src/sampletones_application/ui/elements/window.py
+++ b/src/sampletones_application/ui/elements/window.py
@@ -7,10 +7,11 @@
from sampletones_application.tags.general import TAG_GLOBAL_THEME_DIALOG_WINDOW
from sampletones_application.ui.elements.panel import GUIPanel
from sampletones_application.ui.themes.registry import ThemeRegistry
-from sampletones_application.utils.gui.align import center_item
+from sampletones_application.utils.gui.align import center_when_settled
from sampletones_application.utils.gui.dpg import dpg_configure_item, dpg_delete_item
from sampletones_application.utils.gui.frame import FrameCallbackManager
from sampletones_shared.types.callback import VoidCallback
+from sampletones_shared.types.data import SerializedData
class GUIWindow(GUIPanel, ABC):
@@ -26,10 +27,12 @@ class GUIWindow(GUIPanel, ABC):
A dialog that raises another modal — a prompt, a countdown — hands the screen
over with ``yield_to`` and takes it back with ``resume``, which is what keeps
the two from competing for the one modal DearPyGui carries at a time.
+
+ A window holding prose of a length it learns at the moment it opens sets
+ ``_fits_content``, which lets it grow past the height it states.
"""
- def center(self) -> None:
- center_item(self.tag)
+ _fits_content: bool = False
def yield_to(self, raise_modal: VoidCallback) -> None:
"""Steps off screen and runs ``raise_modal`` a frame later, so what it raises can open.
@@ -60,35 +63,48 @@ def dialog_window(
) -> Iterator[None]:
"""Open this window's modal frame, with the block's widgets building inside it.
- The window holds the width it states and fits its height to the content it is given, which
- is what lets a field, a combo or a button stretch across it: a stretched item measures one
- pixel inside the region it is offered, so a window sized from its own content would take
- that pixel back on every frame. A stated width settles the geometry in one pass and gives
- every dialog the same reading width whatever it holds.
+ The window holds the width it states, which is what lets a field, a combo or a button
+ stretch across it: a stretched item measures one pixel inside the region it is offered, so
+ a window sized from its own content would take that pixel back on every frame. A stated
+ width settles the geometry in one pass and gives every dialog the same reading width
+ whatever it holds.
+
+ A window that sets ``_fits_content`` reads its stated height as a floor and grows to hold
+ what it is given, so a prompt whose text wraps over several lines shows all of it.
A dialog offers the title bar's close button when ``on_close`` names what closing means,
and omits it otherwise, so the only way out of a window is one the window answers for.
"""
+ geometry: SerializedData = (
+ {"min_size": (self.width, self.height), "autosize": True} if self._fits_content else {"height": self.height}
+ )
with dpg.window(
tag=self.tag,
label=label,
width=self.width,
- height=self.height,
no_resize=True,
no_collapse=True,
no_close=on_close is None,
on_close=on_close,
modal=True,
+ **geometry,
):
yield
def show(self, *args: Any, **kwargs: Any) -> None:
+ """Builds this appearance's tree and centers it once the layout has measured it.
+
+ A window's size is known to DearPyGui only after a frame has drawn it, so the center
+ waits for that frame to arrive on its own. Waiting for it in place would hold the render
+ thread, and a window is raised from wherever a result reaches the screen — including the
+ callback drain that runs between frames, where the frame being waited for is the one this
+ call is standing in the way of.
+ """
self.hide()
self.prepare(*args, **kwargs)
self.create_window()
ThemeRegistry.get(TAG_GLOBAL_THEME_DIALOG_WINDOW).bind_to_item(self.tag)
- dpg.split_frame()
- self.center()
+ center_when_settled(self.tag)
def hide(self) -> None:
self._teardown()
diff --git a/src/sampletones_application/ui/menu.py b/src/sampletones_application/ui/menu.py
index 0ef75f5d0..56eb3c446 100644
--- a/src/sampletones_application/ui/menu.py
+++ b/src/sampletones_application/ui/menu.py
@@ -1,5 +1,5 @@
from functools import partial
-from typing import Callable, Dict, Final, Optional, Tuple
+from typing import Callable, Dict, Final, Tuple
import dearpygui.dearpygui as dpg
@@ -8,20 +8,24 @@
ContextElements,
MenuElements,
)
+from sampletones_application.categories.elements.sequencer import (
+ SequencerVoicesElements,
+)
+from sampletones_application.categories.exports import (
+ EXPORT_PROJECT_MENU_LABELS,
+ EXPORT_SAMPLE_MENU_LABELS,
+)
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.constants.playback import FollowMode
from sampletones_application.layout.glyphs.player import PlayerGlyphs
from sampletones_application.layout.player import PlayerLayout
from sampletones_application.tags.compose import compose_tag
from sampletones_application.tags.general import (
- SUF_HANDLER_REGISTRY,
TAG_GLOBAL_MENU_GROUP_EDIT,
TAG_GLOBAL_MENU_GROUP_EDIT_MARKER,
+ TAG_GLOBAL_MENU_GROUP_VOICE,
+ TAG_GLOBAL_MENU_GROUP_VOICE_MARKER,
TAG_GLOBAL_MENU_ITEM_EDIT_REDO,
TAG_GLOBAL_MENU_ITEM_EDIT_UNDO,
TAG_GLOBAL_MENU_ITEM_FILE_CLOSE_PROJECT,
@@ -56,6 +60,10 @@
TAG_GLOBAL_MENU_ITEM_VIEW_AUTO_EXPAND_FAVORITE_RECONSTRUCTIONS,
TAG_GLOBAL_MENU_ITEM_VIEW_FULLSCREEN,
TAG_GLOBAL_MENU_ITEM_VIEW_SHOW_ADVANCED_SETTINGS,
+ TAG_GLOBAL_MENU_ITEM_VOICE_ADD_SAMPLE,
+ TAG_GLOBAL_MENU_ITEM_VOICE_ADD_TO_SEQUENCER,
+ TAG_GLOBAL_MENU_ITEM_VOICE_IMPORT_INSTRUMENT,
+ TAG_GLOBAL_MENU_ITEM_VOICE_NEW_INSTRUMENT,
TAG_GLOBAL_PANEL_PLAYER,
TAG_GLOBAL_TEXT_MENU_FPS,
)
@@ -67,14 +75,13 @@
)
from sampletones_application.ui.elements.fonts.font import Font
from sampletones_application.ui.elements.fonts.registry import FontRegistry
+from sampletones_application.ui.elements.menu_section import MenuSection
from sampletones_application.ui.panels.player.controls import (
create_compact_transport_controls,
)
from sampletones_application.ui.themes.theme import Theme
from sampletones_application.utils.gui.dpg import (
- dpg_append_items,
dpg_configure_item,
- dpg_delete_item,
dpg_set_item_label,
dpg_set_value,
)
@@ -87,8 +94,7 @@
)
from sampletones_application.utils.gui.shortcuts.manager import ShortcutManager
from sampletones_application.view_model.shared.menu import MenuBarViewModel
-from sampletones_core.constants.enums import GeneratorName
-from sampletones_shared.types.application import Sender
+from sampletones_core.constants.enums import ChannelName
from sampletones_shared.types.callback import VoidCallback
PROJECT_ITEM_TAGS: Final[Tuple[str, ...]] = (
@@ -129,10 +135,11 @@ def __init__(
player_layout: PlayerLayout,
language_manager: LanguageManager,
build_edit_actions: Callable[[], bool],
+ build_voice_actions: VoidCallback,
on_play_from_start: VoidCallback,
on_pause_or_resume: VoidCallback,
on_stop: VoidCallback,
- on_channel_muted: Callable[[GeneratorName], None],
+ on_channel_muted: Callable[[ChannelName], None],
) -> None:
self._shortcut_manager = shortcut_manager
self._fps_theme = fps_theme
@@ -154,12 +161,16 @@ def __init__(
self._pause_tooltip_tag = compose_tag(self._pause_button_tag, SUF_PLAYER_TOOLTIP)
self._lbl_pause = language_manager["global.player.label.pause"]
- self._edit_actions_handler_tag = compose_tag(
- TAG_GLOBAL_MENU_GROUP_EDIT_MARKER,
- SUF_HANDLER_REGISTRY,
+ self._edit_section = MenuSection(
+ menu_tag=TAG_GLOBAL_MENU_GROUP_EDIT,
+ marker_tag=TAG_GLOBAL_MENU_GROUP_EDIT_MARKER,
+ build=self._add_edit_action_items,
+ )
+ self._voice_section = MenuSection(
+ menu_tag=TAG_GLOBAL_MENU_GROUP_VOICE,
+ marker_tag=TAG_GLOBAL_MENU_GROUP_VOICE_MARKER,
+ build=build_voice_actions,
)
- self._edit_actions_frame: Optional[int] = None
- self._edit_action_items: Tuple[Sender, ...] = ()
def _label(self, element: MenuElements) -> str:
return self._language_manager[
@@ -172,11 +183,21 @@ def _label(self, element: MenuElements) -> str:
def _context_label(self, element: ContextElements) -> str:
return context_label(self._language_manager, element)
+ def _voices_label(self, element: SequencerVoicesElements) -> str:
+ """The word the voices panel states for an action, so every door onto it reads alike."""
+ return self._language_manager[
+ Page.SEQUENCER,
+ Panel.VOICES,
+ TextType.LABEL,
+ element,
+ ]
+
def create(self, state: MenuBarViewModel) -> None:
with dpg.menu_bar():
self._create_file_menu(state)
self._create_edit_menu(state)
self._create_reconstruction_menu(state)
+ self._create_voice_menu(state)
self._create_playback_menu(state)
self._create_view_menu()
self._create_help_menu()
@@ -236,7 +257,7 @@ def _create_file_menu(self, state: MenuBarViewModel) -> None:
)
def _create_project_export_menu(self, state: MenuBarViewModel) -> None:
- """Builds the submenu that writes the open project for one tracker.
+ """Builds the submenu that writes the open project for one format.
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
@@ -247,25 +268,22 @@ def _create_project_export_menu(self, state: MenuBarViewModel) -> None:
label=self._label(MenuElements.GROUP_FILE_EXPORT),
enabled=state.project_open,
):
- for tracker_format, shortcut_id in PROJECT_EXPORT_SHORTCUT_IDS.items():
+ for export_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]),
+ label=self._label(EXPORT_PROJECT_MENU_LABELS[export_format]),
)
def _create_edit_menu(self, state: MenuBarViewModel) -> None:
"""Builds the Edit menu: the history steps, then the actions of whoever holds the cursor.
- The actions are stated into the menu itself and taken away again on each opening, so they
- follow the cursor. A marker leads the menu, holding nothing and reporting the popup drawn:
- a container standing below a menu item takes the width those items span as its own, which
- the popup then grows to fit on every frame it stays open.
+ The actions are a :class:`MenuSection`, restated on each opening so they follow the cursor.
"""
with dpg.menu(
label=self._label(MenuElements.GROUP_EDIT),
tag=TAG_GLOBAL_MENU_GROUP_EDIT,
):
- dpg.add_group(tag=TAG_GLOBAL_MENU_GROUP_EDIT_MARKER)
+ self._edit_section.add_marker()
self._shortcut_manager.add_menu_item(
ShortcutId.UNDO,
tag=TAG_GLOBAL_MENU_ITEM_EDIT_UNDO,
@@ -280,55 +298,15 @@ def _create_edit_menu(self, state: MenuBarViewModel) -> None:
)
dpg.add_separator()
- with dpg.item_handler_registry(tag=self._edit_actions_handler_tag):
- dpg.add_item_visible_handler(callback=self._on_edit_actions_drawn)
-
- dpg.bind_item_handler_registry(
- TAG_GLOBAL_MENU_GROUP_EDIT_MARKER,
- self._edit_actions_handler_tag,
- )
- self._refresh_edit_actions()
-
- def _on_edit_actions_drawn(
- self,
- _sender: Sender,
- _app_data: Sender,
- ) -> None:
- """States the actions afresh each time the Edit menu is opened.
-
- DearPyGui reports the marker drawn once a frame while the menu stands open, so a gap in
- those reports marks a fresh opening. The actions stay standing between openings, which
- gives the popup its full height on the frame it appears, and the rebuilt ones take over a
- frame later — long before an item can be reached and chosen.
- """
- frame = dpg.get_frame_count()
- reopened = self._edit_actions_frame is None or frame - self._edit_actions_frame > 1
- self._edit_actions_frame = frame
- if reopened:
- self._refresh_edit_actions()
-
- def _refresh_edit_actions(self) -> None:
- """Takes the standing actions out of the Edit menu and asks the focused surface for its own.
-
- A surface builds the same actions its own cell menu offers, so the two doors print one set
- with the keys and the enablement each action carries. The history steps above them stand
- where they are, since only what the last build stated is taken away.
- """
- for item in self._edit_action_items:
- dpg_delete_item(item)
-
- self._edit_action_items = dpg_append_items(
- TAG_GLOBAL_MENU_GROUP_EDIT,
- self._add_edit_action_items,
- )
+ self._edit_section.watch()
def _add_edit_action_items(self) -> None:
- """States the focused surface's actions, or the clipboard four greyed out while none is."""
+ """States the focused surface's actions, or the clipboard four grayed out while none is."""
if not self._build_edit_actions():
self._add_unfocused_clipboard_items()
def _add_unfocused_clipboard_items(self) -> None:
- """Names the clipboard actions greyed out, the Edit menu with no grid holding a cursor."""
+ """Names the clipboard actions grayed out, the Edit menu with no grid holding a cursor."""
for element in UNFOCUSED_CLIPBOARD_ELEMENTS:
dpg.add_menu_item(
label=self._context_label(element),
@@ -410,22 +388,64 @@ def _create_reconstruction_menu(self, state: MenuBarViewModel) -> None:
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.
+ """Builds the submenu that writes the loaded reconstruction's slices for one format.
- 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.
+ Each format able to write a file per slice gets its own item, so choosing the format
+ is one click and the destination dialog then offers that format'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():
+ for export_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]),
+ label=self._label(EXPORT_SAMPLE_MENU_LABELS[export_format]),
)
+ def _create_voice_menu(self, state: MenuBarViewModel) -> None:
+ """Builds the Voice menu: the ways a voice comes in, then what the chosen one offers.
+
+ A voice is written by hand, converted from a recording, brought from a tracker's own
+ instrument file, or taken from the reconstruction the Reconstructions tab holds, and the
+ four stand together here so the bar answers "how do I get a voice in" on its own. The
+ chosen voice's actions are a :class:`MenuSection` the voices panel builds, the same set
+ its row menu prints.
+ """
+ with dpg.menu(
+ label=self._label(MenuElements.GROUP_VOICE),
+ tag=TAG_GLOBAL_MENU_GROUP_VOICE,
+ ):
+ self._voice_section.add_marker()
+ self._shortcut_manager.add_menu_item(
+ ShortcutId.NEW_INSTRUMENT,
+ tag=TAG_GLOBAL_MENU_ITEM_VOICE_NEW_INSTRUMENT,
+ label=self._voices_label(SequencerVoicesElements.NEW_INSTRUMENT),
+ enabled=state.project_open,
+ )
+ self._shortcut_manager.add_menu_item(
+ ShortcutId.ADD_SAMPLE_FROM_FILE,
+ tag=TAG_GLOBAL_MENU_ITEM_VOICE_ADD_SAMPLE,
+ label=self._voices_label(SequencerVoicesElements.ADD_SAMPLE),
+ enabled=state.project_open,
+ )
+ self._shortcut_manager.add_menu_item(
+ ShortcutId.IMPORT_INSTRUMENT,
+ tag=TAG_GLOBAL_MENU_ITEM_VOICE_IMPORT_INSTRUMENT,
+ label=self._voices_label(SequencerVoicesElements.IMPORT_INSTRUMENT),
+ enabled=state.project_open,
+ )
+ dpg.add_separator()
+ self._shortcut_manager.add_menu_item(
+ ShortcutId.ADD_RECONSTRUCTION_TO_SEQUENCER,
+ tag=TAG_GLOBAL_MENU_ITEM_VOICE_ADD_TO_SEQUENCER,
+ label=self._context_label(ContextElements.ADD_TO_SEQUENCER),
+ enabled=state.add_to_sequencer_enabled,
+ )
+
+ self._voice_section.watch()
+
def _create_playback_menu(self, state: MenuBarViewModel) -> None:
with dpg.menu(label=self._label(MenuElements.GROUP_PLAYBACK)):
self._shortcut_manager.add_menu_item(
@@ -507,14 +527,14 @@ def _create_channels_menu(self, state: MenuBarViewModel) -> None:
tag=TAG_GLOBAL_MENU_ITEM_PLAYBACK_CHANNELS,
label=self._label(MenuElements.GROUP_PLAYBACK_CHANNELS),
):
- for generator, shortcut_id in CHANNEL_SHORTCUT_IDS.items():
+ for channel, shortcut_id in CHANNEL_SHORTCUT_IDS.items():
self._shortcut_manager.add_menu_item(
shortcut_id,
- callback=partial(self._on_channel_muted, generator),
- tag=self._channel_menu_item_tag(generator),
- label=channel_label(self._language_manager, generator),
+ callback=partial(self._on_channel_muted, channel),
+ tag=self._channel_menu_item_tag(channel),
+ label=channel_label(self._language_manager, channel),
check=True,
- default_value=not state.channels.is_muted(generator),
+ default_value=not state.channels.is_muted(channel),
)
dpg.add_separator()
self._shortcut_manager.add_menu_item(
@@ -648,6 +668,22 @@ def update(self, state: MenuBarViewModel) -> None:
TAG_GLOBAL_MENU_ITEM_RECONSTRUCTION_ADD_TO_SEQUENCER,
enabled=state.add_to_sequencer_enabled,
)
+ dpg_configure_item(
+ TAG_GLOBAL_MENU_ITEM_VOICE_NEW_INSTRUMENT,
+ enabled=state.project_open,
+ )
+ dpg_configure_item(
+ TAG_GLOBAL_MENU_ITEM_VOICE_ADD_SAMPLE,
+ enabled=state.project_open,
+ )
+ dpg_configure_item(
+ TAG_GLOBAL_MENU_ITEM_VOICE_IMPORT_INSTRUMENT,
+ enabled=state.project_open,
+ )
+ dpg_configure_item(
+ TAG_GLOBAL_MENU_ITEM_VOICE_ADD_TO_SEQUENCER,
+ enabled=state.add_to_sequencer_enabled,
+ )
dpg_configure_item(
TAG_GLOBAL_MENU_ITEM_RECONSTRUCTION_OPEN_IN_EXPLORER,
enabled=state.open_in_explorer_enabled,
@@ -705,8 +741,8 @@ def _update_follow_mode(self, state: MenuBarViewModel) -> None:
def _update_channels(self, state: MenuBarViewModel) -> None:
"""Shows the mute set the sequencer's tables show: a check on every channel that sounds."""
- for generator in CHANNEL_SHORTCUT_IDS:
- dpg_set_value(self._channel_menu_item_tag(generator), not state.channels.is_muted(generator))
+ for channel in CHANNEL_SHORTCUT_IDS:
+ dpg_set_value(self._channel_menu_item_tag(channel), not state.channels.is_muted(channel))
dpg_configure_item(
TAG_GLOBAL_MENU_ITEM_PLAYBACK_UNMUTE_ALL_CHANNELS,
@@ -741,9 +777,9 @@ def update_fps(self, fps: float) -> None:
)
@staticmethod
- def _channel_menu_item_tag(generator: GeneratorName) -> str:
- """The tag of the Channels submenu item that switches ``generator``."""
- return compose_tag(TAG_GLOBAL_MENU_ITEM_PLAYBACK_CHANNELS, generator.value)
+ def _channel_menu_item_tag(channel: ChannelName) -> str:
+ """The tag of the Channels submenu item that switches ``channel``."""
+ return compose_tag(TAG_GLOBAL_MENU_ITEM_PLAYBACK_CHANNELS, channel.value)
@staticmethod
def _follow_menu_item_tag(mode: FollowMode) -> str:
diff --git a/src/sampletones_application/ui/panels/dialogs/audio_settings.py b/src/sampletones_application/ui/panels/dialogs/audio_settings.py
index c45b76d67..ae5c758c8 100644
--- a/src/sampletones_application/ui/panels/dialogs/audio_settings.py
+++ b/src/sampletones_application/ui/panels/dialogs/audio_settings.py
@@ -206,7 +206,7 @@ def _master_gain_readout(self, gain: float) -> MasterGainReadout:
)
def _clip_warning_color(self, clip_fraction: float) -> ColorRGBA:
- """Reddens the readout colour along the layout gradient by the projected boost fraction."""
+ """Reddens the readout color along the layout gradient by the projected boost fraction."""
colors = self._layout.audio.master_gain
return blend(colors.label_color.rgba, colors.clip_color.rgba, clip_fraction)
diff --git a/src/sampletones_application/ui/panels/dialogs/export.py b/src/sampletones_application/ui/panels/dialogs/export.py
new file mode 100644
index 000000000..0fa69707d
--- /dev/null
+++ b/src/sampletones_application/ui/panels/dialogs/export.py
@@ -0,0 +1,212 @@
+from typing import Any, Dict, Optional
+
+import dearpygui.dearpygui as dpg
+
+from sampletones_application.categories.manager import LanguageManager
+from sampletones_application.layout.general.colors.text import TextColors
+from sampletones_application.layout.settings import SettingsLayout
+from sampletones_application.tags.compose import compose_tag
+from sampletones_application.tags.settings import (
+ TAG_SETTINGS_EXPORT_BUTTON_CANCEL,
+ TAG_SETTINGS_EXPORT_GROUP_MEASURED,
+ TAG_SETTINGS_EXPORT_GROUP_STAGES,
+ TAG_SETTINGS_EXPORT_GROUP_WORKING,
+ TAG_SETTINGS_EXPORT_PROGRESS,
+ TAG_SETTINGS_EXPORT_TEXT_FIGURE,
+ TAG_SETTINGS_EXPORT_TEXT_STAGE,
+ TAG_SETTINGS_EXPORT_WINDOW,
+)
+from sampletones_application.ui.elements.button import GUIButton
+from sampletones_application.ui.elements.dialog import GUIDialogWindow
+from sampletones_application.ui.elements.fonts.font import Font
+from sampletones_application.ui.elements.fonts.registry import FontRegistry
+from sampletones_application.utils.gui.dialog_navigation import FocusStop
+from sampletones_application.utils.gui.dpg import dpg_configure_item, dpg_set_value
+from sampletones_application.utils.gui.keyboard import KeyRouter
+from sampletones_application.utils.gui.palette.dpg import dpg_set_palette_color
+from sampletones_application.utils.gui.shortcuts.source import ShortcutSource
+from sampletones_application.utils.palette.colors.base import BaseColor
+from sampletones_application.view_model.shared.export import SongExportViewModel
+from sampletones_core.exports.stage import ExportStage
+from sampletones_shared.types.callback import VoidCallback
+
+
+class GUIExportWindow(GUIDialogWindow):
+ """Modal report over an export while it runs.
+
+ The file itself was named in the system's own save dialog, so this window has one face: what
+ the run is doing. The stages appear as the format reaches them, the one under way sitting at
+ the foot of the list, and it carries either a bar filling toward its end or the turning
+ indicator of work whose length the data decides.
+
+ Cancelling is offered for as long as the run can still answer one.
+ """
+
+ _fits_content = True
+
+ def __init__(
+ self,
+ *,
+ layout: SettingsLayout,
+ text_colors: TextColors,
+ language_manager: LanguageManager,
+ key_router: KeyRouter,
+ shortcut_source: ShortcutSource,
+ ) -> None:
+ self._language_manager = language_manager
+ self._text_colors = text_colors
+ self._view_model: SongExportViewModel = SongExportViewModel.idle()
+
+ self.on_cancel: Optional[VoidCallback] = None
+
+ self._stage_labels: Dict[ExportStage, str] = {
+ ExportStage.WALKING: language_manager["settings.export.label.stage_walking"],
+ ExportStage.COMPRESSING: language_manager["settings.export.label.stage_compressing"],
+ ExportStage.WRITING: language_manager["settings.export.label.stage_writing"],
+ }
+
+ super().__init__(
+ tag=TAG_SETTINGS_EXPORT_WINDOW,
+ width=layout.export.window.width,
+ height=layout.export.window.height,
+ key_router=key_router,
+ shortcut_source=shortcut_source,
+ )
+
+ def open(self, view_model: SongExportViewModel) -> None:
+ """Shows the window over the run that has just begun."""
+ self._view_model = view_model
+ self.show()
+
+ def prepare(self, *_args: Any, **_kwargs: Any) -> None:
+ """The drawn values are seeded by :meth:`open` before the tree rebuilds."""
+
+ def update_view(self, view_model: SongExportViewModel) -> None:
+ """Re-draws the open window from where the run stands."""
+ self._view_model = view_model
+ self._render()
+
+ def create_window(self) -> None:
+ with self.dialog_window(
+ label=self._language_manager["settings.export.title.window_title"],
+ on_close=None,
+ ):
+ self._create_stages()
+ self._create_measured()
+ self._create_working()
+ dpg.add_separator()
+ self._create_cancel()
+
+ self._render()
+ self._install_navigation(
+ [
+ FocusStop.button(
+ TAG_SETTINGS_EXPORT_BUTTON_CANCEL,
+ self._request_cancel,
+ )
+ ],
+ on_escape=self._request_cancel,
+ )
+
+ def _create_stages(self) -> None:
+ with dpg.group(tag=TAG_SETTINGS_EXPORT_GROUP_STAGES):
+ for stage in ExportStage:
+ tag = self._stage_tag(stage)
+ dpg.add_text(
+ self._stage_labels[stage],
+ tag=tag,
+ show=False,
+ )
+ FontRegistry.bind_to_item(tag, Font.REGULAR)
+
+ def _create_measured(self) -> None:
+ with dpg.group(
+ tag=TAG_SETTINGS_EXPORT_GROUP_MEASURED,
+ show=False,
+ ):
+ dpg.add_progress_bar(
+ tag=TAG_SETTINGS_EXPORT_PROGRESS,
+ default_value=0.0,
+ width=-1,
+ )
+ FontRegistry.bind_to_item(
+ TAG_SETTINGS_EXPORT_PROGRESS,
+ Font.MONO,
+ )
+
+ def _create_working(self) -> None:
+ """The reading of a stage whose length the data decides: what it holds, and that it turns.
+
+ A bar would have to state a fraction of something, and this stage travels toward nothing,
+ so what stands here is the figure it does know beside a symbol that keeps moving.
+ """
+ with dpg.group(
+ tag=TAG_SETTINGS_EXPORT_GROUP_WORKING,
+ show=False,
+ horizontal=True,
+ ):
+ dpg.add_loading_indicator(
+ style=1,
+ radius=2.0,
+ thickness=1.5,
+ )
+ dpg.add_text("", tag=TAG_SETTINGS_EXPORT_TEXT_FIGURE)
+ FontRegistry.bind_to_item(
+ TAG_SETTINGS_EXPORT_TEXT_FIGURE,
+ Font.MONO_SMALL,
+ )
+
+ def _create_cancel(self) -> None:
+ GUIButton(
+ tag=TAG_SETTINGS_EXPORT_BUTTON_CANCEL,
+ label=self._language_manager["global.dialog.label.cancel"],
+ callback=self._request_cancel,
+ width=-1,
+ )
+
+ def _render(self) -> None:
+ """Draws the run as it stands: what it has been through, and where the latest stage is."""
+ view_model = self._view_model
+ self._render_stages(view_model)
+ dpg_configure_item(
+ TAG_SETTINGS_EXPORT_GROUP_MEASURED,
+ show=view_model.progress_visible,
+ )
+ dpg_set_value(TAG_SETTINGS_EXPORT_PROGRESS, view_model.progress)
+ dpg_configure_item(
+ TAG_SETTINGS_EXPORT_PROGRESS,
+ overlay=view_model.progress_overlay,
+ )
+ dpg_configure_item(
+ TAG_SETTINGS_EXPORT_GROUP_WORKING,
+ show=view_model.working_visible,
+ )
+ dpg_set_value(TAG_SETTINGS_EXPORT_TEXT_FIGURE, view_model.figure)
+ dpg_configure_item(
+ TAG_SETTINGS_EXPORT_BUTTON_CANCEL,
+ enabled=view_model.cancel_enabled,
+ )
+
+ def _render_stages(self, view_model: SongExportViewModel) -> None:
+ """Lists what the run has reached, the stage under way reading ahead of the ones behind."""
+ for stage in ExportStage:
+ tag = self._stage_tag(stage)
+ dpg_configure_item(tag, show=view_model.stage_visible(stage))
+ dpg_set_palette_color(tag, self._stage_color(view_model, stage))
+
+ def _stage_color(
+ self,
+ view_model: SongExportViewModel,
+ stage: ExportStage,
+ ) -> BaseColor:
+ if view_model.stage_reached(stage):
+ return self._text_colors.disabled
+
+ return self._text_colors.default
+
+ def _stage_tag(self, stage: ExportStage) -> str:
+ return compose_tag(TAG_SETTINGS_EXPORT_TEXT_STAGE, stage.value)
+
+ def _request_cancel(self) -> None:
+ if self._view_model.cancel_enabled:
+ self.call(self.on_cancel)
diff --git a/src/sampletones_application/ui/panels/dialogs/keybindings.py b/src/sampletones_application/ui/panels/dialogs/keybindings.py
index c5db0248f..b5ca658e5 100644
--- a/src/sampletones_application/ui/panels/dialogs/keybindings.py
+++ b/src/sampletones_application/ui/panels/dialogs/keybindings.py
@@ -256,13 +256,13 @@ def _create_action_buttons(self) -> None:
)
def _install_capture(self) -> None:
- """Readies the capture that reads a press, cancelled by whatever a dialog is cancelled by."""
+ """Readies the capture that reads a press, canceled by whatever a dialog is canceled by."""
self._capture = KeyCapture(
key_router=self._router,
cancel=self._shortcuts.shortcut(ShortcutId.DIALOG_CANCEL).combinations(),
)
self._capture.on_captured = self._report_captured
- self._capture.on_cancelled = self._render
+ self._capture.on_canceled = self._render
def _teardown(self) -> None:
"""Stops the capture this appearance armed before the keyboard claim is released."""
diff --git a/src/sampletones_application/ui/panels/dialogs/project_properties.py b/src/sampletones_application/ui/panels/dialogs/project_properties.py
index 1254a6666..1b8e9d188 100644
--- a/src/sampletones_application/ui/panels/dialogs/project_properties.py
+++ b/src/sampletones_application/ui/panels/dialogs/project_properties.py
@@ -44,7 +44,7 @@
class GUIProjectPropertiesWindow(GUIDialogWindow):
- """Modal form to view and edit the project's title, author, comment, and metre.
+ """Modal form to view and edit the project's title, author, comment, and meter.
Each appearance renders the view model handed to :meth:`open`, and the edited
values reach the ``on_commit`` hook on confirmation, so the owner applies
diff --git a/src/sampletones_application/ui/panels/dialogs/stem_selection.py b/src/sampletones_application/ui/panels/dialogs/stem_selection.py
new file mode 100644
index 000000000..b8751d5a6
--- /dev/null
+++ b/src/sampletones_application/ui/panels/dialogs/stem_selection.py
@@ -0,0 +1,141 @@
+from pathlib import Path
+from typing import Any, Callable, Final, List, Optional, Sequence, Tuple
+
+import dearpygui.dearpygui as dpg
+
+from sampletones_application.layout.tabs.main.converter import ConverterLayout
+from sampletones_application.tags.compose import compose_tag
+from sampletones_application.tags.main import (
+ PRE_MAIN_CONVERTER_CANDIDATE,
+ TAG_MAIN_CONVERTER_BUTTON_ADD_STEMS,
+ TAG_MAIN_CONVERTER_BUTTON_CANCEL_STEMS,
+ TAG_MAIN_CONVERTER_GROUP_STEM_SELECTION,
+ TAG_MAIN_CONVERTER_TEXT_STEM_SELECTION_LIMIT,
+ TAG_MAIN_CONVERTER_WINDOW_STEM_SELECTION,
+)
+from sampletones_application.ui.elements.button import GUIButton
+from sampletones_application.ui.elements.dialog import GUIDialogWindow
+from sampletones_application.utils.gui.align import table_wrapper
+from sampletones_application.utils.gui.dialog_navigation import FocusStop
+from sampletones_application.utils.gui.keyboard import KeyRouter
+from sampletones_application.utils.gui.shortcuts.source import ShortcutSource
+
+ADD_FOCUS_STOP: Final[int] = 1
+
+
+class GUIStemSelectionWindow(GUIDialogWindow):
+ """A modal offering the recordings a folder holds, with the ones that fit already ticked.
+
+ A folder can hold more recordings than one conversion has room for, so the reader is shown
+ what was found and which of it fits: the first ones up to the room left arrive ticked, the
+ rest stand disabled beneath a line stating the limit. What comes back is the reader's choice.
+ """
+
+ def __init__(
+ self,
+ *,
+ layout: ConverterLayout,
+ title: str,
+ message: str,
+ limit_template: str,
+ add_label: str,
+ cancel_label: str,
+ key_router: KeyRouter,
+ shortcut_source: ShortcutSource,
+ ) -> None:
+ self._title = title
+ self._message = message
+ self._limit_template = limit_template
+ self._add_label = add_label
+ self._cancel_label = cancel_label
+ self._candidates: Tuple[Path, ...] = ()
+ self._room = 0
+ self._footer_height = layout.stem_selection_footer
+
+ self.on_add: Optional[Callable[[List[Path]], None]] = None
+
+ super().__init__(
+ tag=TAG_MAIN_CONVERTER_WINDOW_STEM_SELECTION,
+ width=layout.stem_selection.width,
+ height=layout.stem_selection.height,
+ key_router=key_router,
+ shortcut_source=shortcut_source,
+ )
+
+ def open(self, candidates: Sequence[Path], room: int) -> None:
+ """Shows the recordings found, ticking as many as the conversion still has room for."""
+ self._candidates = tuple(candidates)
+ self._room = room
+ self.show()
+
+ def prepare(self, *_args: Any, **_kwargs: Any) -> None:
+ """The candidates and the room left are seeded by :meth:`open` before the tree rebuilds."""
+
+ def create_window(self) -> None:
+ with self.dialog_window(label=self._title, on_close=None):
+ dpg.add_text(self._message, wrap=self.width)
+ dpg.add_text(self._limit_text(), tag=TAG_MAIN_CONVERTER_TEXT_STEM_SELECTION_LIMIT)
+ dpg.add_separator()
+ with dpg.child_window(
+ tag=TAG_MAIN_CONVERTER_GROUP_STEM_SELECTION,
+ width=-1,
+ height=-self._footer_height,
+ border=False,
+ ):
+ self._create_candidate_rows()
+
+ dpg.add_separator()
+ self._create_action_buttons()
+
+ self._install_navigation(
+ [
+ FocusStop.button(TAG_MAIN_CONVERTER_BUTTON_CANCEL_STEMS, self.hide),
+ FocusStop.button(TAG_MAIN_CONVERTER_BUTTON_ADD_STEMS, self._add),
+ ],
+ on_escape=self.hide,
+ initial_index=ADD_FOCUS_STOP,
+ )
+
+ def _create_candidate_rows(self) -> None:
+ for index, candidate in enumerate(self._candidates):
+ fits = index < self._room
+ dpg.add_checkbox(
+ label=candidate.name,
+ tag=self._candidate_tag(candidate),
+ default_value=fits,
+ enabled=fits,
+ )
+
+ @table_wrapper(columns=2)
+ def _create_action_buttons(self) -> None:
+ GUIButton(
+ tag=TAG_MAIN_CONVERTER_BUTTON_CANCEL_STEMS,
+ label=self._cancel_label,
+ callback=self.hide,
+ width=-1,
+ )
+ GUIButton(
+ tag=TAG_MAIN_CONVERTER_BUTTON_ADD_STEMS,
+ label=self._add_label,
+ callback=self._add,
+ width=-1,
+ )
+
+ def _limit_text(self) -> str:
+ return self._limit_template.format(self._room, len(self._candidates))
+
+ def _selected(self) -> List[Path]:
+ return [
+ candidate
+ for candidate in self._candidates
+ if dpg.does_item_exist(self._candidate_tag(candidate)) and dpg.get_value(self._candidate_tag(candidate))
+ ]
+
+ def _add(self) -> None:
+ selected = self._selected()
+ self.hide()
+ self.call(self.on_add, selected)
+
+ @staticmethod
+ def _candidate_tag(candidate: Path) -> str:
+ return compose_tag(PRE_MAIN_CONVERTER_CANDIDATE, str(candidate))
diff --git a/src/sampletones_application/ui/panels/instruction/colors.py b/src/sampletones_application/ui/panels/instruction/colors.py
new file mode 100644
index 000000000..19c19854f
--- /dev/null
+++ b/src/sampletones_application/ui/panels/instruction/colors.py
@@ -0,0 +1,28 @@
+from typing import Any
+
+from sampletones_application.layout.general.colors.channel import ChannelColors
+from sampletones_application.utils.palette.colors.written import WrittenColor
+from sampletones_core.features import generator_channel
+from sampletones_core.generators import CLASS_NAME_TO_GENERATOR_MAP
+from sampletones_core.library import InstructionLibraryFragment
+
+
+def fragment_color(
+ channel_colors: ChannelColors,
+ fragment: InstructionLibraryFragment[Any],
+) -> WrittenColor:
+ """The color one library fragment is drawn in, which is its generator's own.
+
+ A fragment names the generator that made it, and a generator is heard on a channel the
+ application already paints by, so the waveform and the spectrum of a pulse fragment read as
+ the pulse wherever they are shown.
+
+ Args:
+ channel_colors: The palette every view naming a channel paints from.
+ fragment: The fragment being drawn.
+
+ Returns:
+ WrittenColor: The color its generator is known by.
+ """
+ generator_name = CLASS_NAME_TO_GENERATOR_MAP[fragment.generator_class]
+ return channel_colors.for_channel(generator_channel(generator_name))
diff --git a/src/sampletones_application/ui/panels/instruction/library.py b/src/sampletones_application/ui/panels/instruction/library.py
index 5c98d0921..23186dad4 100644
--- a/src/sampletones_application/ui/panels/instruction/library.py
+++ b/src/sampletones_application/ui/panels/instruction/library.py
@@ -39,7 +39,7 @@
from sampletones_application.utils.gui.dpg import dpg_configure_item, dpg_set_value
from sampletones_application.utils.gui.tooltip import attach_disabled_tooltip
from sampletones_application.view_model.instruction.library import LibraryPanelViewModel
-from sampletones_core.constants.enums import LibraryGeneratorName
+from sampletones_core.constants.enums import GeneratorName
from sampletones_core.library import InstructionLibraryKey
from sampletones_core.structures.tree import (
GeneratorNode,
@@ -55,7 +55,7 @@
class LibraryLogicProtocol(Protocol):
- """The library-catalogue contract ``GUIInstructionsLibraryPanel`` drives.
+ """The library-catalog contract ``GUIInstructionsLibraryPanel`` drives.
Typing the collaborator structurally keeps the panel bound to the queries
its rendering needs — the current-library check runs per node, and the
@@ -78,7 +78,7 @@ def get_path(self, key: InstructionLibraryKey) -> Path: ...
class GUIInstructionsLibraryPanel(GUIFileBrowserPanel):
- """The Instructions tab's catalogue of instruction libraries and the generators inside them."""
+ """The Instructions tab's catalog of instruction libraries and the generators inside them."""
_NAME_FONT: Font = Font.REGULAR_SMALL
_MONOSPACE_CONFIG_NODES: bool = True
@@ -115,7 +115,7 @@ def __init__(
self.on_generate_requested: Optional[VoidCallback] = None
self.on_cancel_generation: Optional[VoidCallback] = None
self.on_library_selected: Optional[Callable[[InstructionLibraryKey], None]] = None
- self.on_generator_selected: Optional[Callable[[InstructionLibraryKey, LibraryGeneratorName], None]] = None
+ self.on_generator_selected: Optional[Callable[[InstructionLibraryKey, GeneratorName], None]] = None
self.on_library_remove_requested: Optional[Callable[[InstructionLibraryKey], None]] = None
super().__init__(
@@ -166,9 +166,9 @@ def _setup_handlers(self) -> None:
super()._setup_handlers()
def _create_controls(self) -> None:
- """Reads out what the catalogue holds, and offers what can be done to it.
+ """Reads out what the catalog holds, and offers what can be done to it.
- The controls come in two sets: the ones a reader picks from while the catalogue sits still,
+ The controls come in two sets: the ones a reader picks from while the catalog sits still,
and the progress bar and cancel button a generation replaces them with.
"""
self._create_library_status()
@@ -279,7 +279,7 @@ def refresh_action_buttons(self) -> None:
Called whenever a long operation starts or finishes. The button stays enabled only while the
panel is unlocked and no conversion or library generation is running, leaving the rest of the
panel usable during such an operation. The cancel button stays enabled so a generation can
- always be cancelled."""
+ always be canceled."""
self._apply_action_button_states()
def _apply_action_button_states(self) -> None:
diff --git a/src/sampletones_application/ui/panels/instruction/spectrum.py b/src/sampletones_application/ui/panels/instruction/spectrum.py
index cd4619bb9..c673ca1b9 100644
--- a/src/sampletones_application/ui/panels/instruction/spectrum.py
+++ b/src/sampletones_application/ui/panels/instruction/spectrum.py
@@ -1,6 +1,7 @@
from typing import Any
from sampletones_application.categories.manager import LanguageManager
+from sampletones_application.layout.general.colors.channel import ChannelColors
from sampletones_application.layout.graphs import GraphsLayout
from sampletones_application.tags.instructions import (
TAG_INSTRUCTIONS_INSTRUCTION_PANEL_INSTRUCTION_SPECTRUM,
@@ -9,6 +10,7 @@
from sampletones_application.ui.elements.graphs.spectrum import GUISpectrumGraph
from sampletones_application.ui.elements.panel import GUIPanel
from sampletones_application.ui.elements.status import GUIStatusBar
+from sampletones_application.ui.panels.instruction.colors import fragment_color
from sampletones_core.library import InstructionLibraryFragment
@@ -17,11 +19,13 @@ def __init__(
self,
*,
layout: GraphsLayout,
+ channel_colors: ChannelColors,
language_manager: LanguageManager,
status_bar: GUIStatusBar,
initial_collapsed: bool = False,
) -> None:
self._layout = layout
+ self._channel_colors = channel_colors
self._language_manager = language_manager
self._status_bar = status_bar
self.display: GUISpectrumGraph
@@ -59,7 +63,13 @@ def load_library_fragment(
sample_rate: int,
frame_length: int,
) -> None:
- self.display.load_library_fragment(fragment, sample_rate, frame_length)
+ """Draws one fragment's bands, brightening toward the color of the generator that made it."""
+ self.display.load_library_fragment(
+ fragment,
+ sample_rate,
+ frame_length,
+ fragment_color(self._channel_colors, fragment),
+ )
def clear_layers(self) -> None:
self.display.clear_layers()
diff --git a/src/sampletones_application/ui/panels/instruction/waveform.py b/src/sampletones_application/ui/panels/instruction/waveform.py
index 4a085368c..dd1ec0e50 100644
--- a/src/sampletones_application/ui/panels/instruction/waveform.py
+++ b/src/sampletones_application/ui/panels/instruction/waveform.py
@@ -1,6 +1,7 @@
from typing import Any
from sampletones_application.categories.manager import LanguageManager
+from sampletones_application.layout.general.colors.channel import ChannelColors
from sampletones_application.layout.graphs import GraphsLayout
from sampletones_application.tags.instructions import (
TAG_INSTRUCTIONS_INSTRUCTION_PANEL_INSTRUCTION_WAVEFORM,
@@ -9,6 +10,7 @@
from sampletones_application.ui.elements.graphs.waveform import GUIWaveformGraph
from sampletones_application.ui.elements.panel import GUIPanel
from sampletones_application.ui.elements.status import GUIStatusBar
+from sampletones_application.ui.panels.instruction.colors import fragment_color
from sampletones_core.library import InstructionLibraryFragment
@@ -17,11 +19,13 @@ def __init__(
self,
*,
layout: GraphsLayout,
+ channel_colors: ChannelColors,
language_manager: LanguageManager,
status_bar: GUIStatusBar,
initial_collapsed: bool = False,
) -> None:
self._layout = layout
+ self._channel_colors = channel_colors
self._language_manager = language_manager
self._status_bar = status_bar
self.display: GUIWaveformGraph
@@ -57,7 +61,11 @@ def load_library_fragment(
self,
fragment: InstructionLibraryFragment[Any],
) -> None:
- self.display.load_library_fragment(fragment)
+ """Draws one fragment's waveform, in the color the generator that made it is known by."""
+ self.display.load_library_fragment(
+ fragment,
+ fragment_color(self._channel_colors, fragment),
+ )
def clear_layers(self) -> None:
self.display.clear_layers()
diff --git a/src/sampletones_application/ui/panels/main/converter.py b/src/sampletones_application/ui/panels/main/converter.py
index a5de9f920..0233a8499 100644
--- a/src/sampletones_application/ui/panels/main/converter.py
+++ b/src/sampletones_application/ui/panels/main/converter.py
@@ -1,35 +1,55 @@
-from typing import Any, Optional
+from pathlib import Path
+from typing import Any, Callable, Dict, FrozenSet, List, Optional, Tuple
import dearpygui.dearpygui as dpg
+from sampletones_application.categories.elements.main import ConverterStemMoveElements
+from sampletones_application.categories.hierarchy import Page, Panel, TextType
from sampletones_application.categories.manager import LanguageManager
+from sampletones_application.constants.conversion import MIN_CHANNEL_CAP
from sampletones_application.layout.general.colors.path import PathColors
+from sampletones_application.layout.general.inputs import InputsLayout
+from sampletones_application.layout.general.stems import StemsListLayout
from sampletones_application.layout.tabs.main.converter import ConverterLayout
+from sampletones_application.tags.compose import compose_tag
from sampletones_application.tags.general import (
+ SUF_HANDLER_REGISTRY,
TAG_GLOBAL_THEME_DANGER_BUTTON,
TAG_GLOBAL_THEME_PANEL_EMPHASIS,
TAG_GLOBAL_THEME_PRIMARY_BUTTON,
)
from sampletones_application.tags.main import (
+ PRE_MAIN_CONVERTER_STEMS,
TAG_MAIN_CONVERTER_BUTTON_ACTION,
+ TAG_MAIN_CONVERTER_CHECKBOX_STEMS_MODE,
+ TAG_MAIN_CONVERTER_COMBO_HIERARCHY_MODE,
TAG_MAIN_CONVERTER_GROUP,
+ TAG_MAIN_CONVERTER_GROUP_CONTROLS,
TAG_MAIN_CONVERTER_GROUP_CONVERT,
TAG_MAIN_CONVERTER_GROUP_SUMMARY,
+ TAG_MAIN_CONVERTER_INPUT_CHANNEL_CAP,
TAG_MAIN_CONVERTER_PANEL,
TAG_MAIN_CONVERTER_PATH_INPUT_PATH,
TAG_MAIN_CONVERTER_PROGRESS,
TAG_MAIN_CONVERTER_TEXT_OUTPUT_PATH,
TAG_MAIN_CONVERTER_TEXT_STATUS,
+ TAG_MAIN_CONVERTER_TEXT_STEMS_HINT,
TAG_MAIN_CONVERTER_TEXT_SUMMARY_HINT,
+ TAG_MAIN_CONVERTER_TOOLTIP_CHANNEL_CAP,
TAG_MAIN_CONVERTER_TOOLTIP_CONVERT,
- TAG_MAIN_CONVERTER_WINDOW_SUMMARY,
+ TAG_MAIN_CONVERTER_TOOLTIP_HIERARCHY_MODE,
+ TAG_MAIN_CONVERTER_TOOLTIP_STEMS_MODE,
+ TAG_MAIN_CONVERTER_WINDOW_STEMS,
)
from sampletones_application.ui.elements.button import GUIButton
+from sampletones_application.ui.elements.context_menu import add_path_menu_items, context_menu
+from sampletones_application.ui.elements.field import labeled_field
from sampletones_application.ui.elements.fonts.font import Font
from sampletones_application.ui.elements.fonts.registry import FontRegistry
from sampletones_application.ui.elements.panel import GUIPanel
from sampletones_application.ui.elements.path import GUIDestinationPathText, GUIPathText
from sampletones_application.ui.elements.status import GUIStatusBar
+from sampletones_application.ui.elements.stems.list import GUIStemsList
from sampletones_application.ui.themes.registry import ThemeRegistry
from sampletones_application.ui.themes.theme import Theme
from sampletones_application.utils.gui.dpg import (
@@ -37,19 +57,44 @@
dpg_set_item_callback,
dpg_set_value,
)
-from sampletones_application.utils.gui.tooltip import attach_disabled_tooltip
+from sampletones_application.utils.gui.tooltip import (
+ attach_disabled_tooltip,
+ set_tooltip_visible,
+ show_tooltip,
+)
+from sampletones_application.utils.gui.widgets import clamp_widget_value
from sampletones_application.view_model.main.converter import (
ConverterAction,
ConverterViewModel,
)
-from sampletones_shared.types.callback import VoidCallback
+from sampletones_application.view_model.shared.stems import StemRowViewModel
+from sampletones_core.constants.enums import ChannelName, HierarchyMode
+from sampletones_shared.types.application import Sender
+from sampletones_shared.types.callback import PathCallback, VoidCallback
+
+PathOffsetCallback = Callable[[Path, int], None]
+
+LEVEL_ABOVE: int = -1
+LEVEL_BELOW: int = 1
+POSITION_EARLIER: int = -1
+POSITION_LATER: int = 1
class GUIConverterPanel(GUIPanel):
+ """The card a conversion is set up on: what it converts, how, and what it is doing.
+
+ In stems mode the card lists the recordings being gathered under the levels they pick on.
+ A row is dragged onto another row to share that row's level, or onto the gap between two
+ levels to open one of its own; the row's menu names the same moves in words and offers the
+ recording's own filesystem actions.
+ """
+
def __init__(
self,
*,
layout: ConverterLayout,
+ stems_layout: StemsListLayout,
+ inputs: InputsLayout,
path_colors: PathColors,
initial_collapsed: bool = False,
language_manager: LanguageManager,
@@ -65,48 +110,85 @@ def __init__(
self.on_convert_requested: Optional[VoidCallback] = None
self.on_cancel_requested: Optional[VoidCallback] = None
+ self.on_stems_mode_changed: Optional[Callable[[bool], None]] = None
+ self.on_channel_cap_changed: Optional[Callable[[int], None]] = None
+ self.on_hierarchy_mode_changed: Optional[Callable[[HierarchyMode], None]] = None
+ self.on_source_channels_changed: Optional[Callable[[Path, FrozenSet[ChannelName]], None]] = None
+ self.on_source_removed: Optional[PathCallback] = None
+ self.on_source_moved: Optional[PathOffsetCallback] = None
+ self.on_source_level_joined: Optional[PathOffsetCallback] = None
+ self.on_source_isolated: Optional[PathCallback] = None
+ self.on_source_dropped_on_source: Optional[Callable[[Path, Path], None]] = None
+ self.on_source_dropped_on_level: Optional[Callable[[Path, int], None]] = None
self._layout = layout
+ self._input_width = inputs.default_width
+ self._label_width = inputs.label_width
self._path_colors = path_colors
self._msg_path = language_manager["global.status.message.path"]
self._msg_destination = language_manager["global.status.message.destination"]
self._msg_status_convert = language_manager["main.converter.message.status_convert"]
self._status_action_message = self._msg_status_convert
-
- super().__init__(
- tag=TAG_MAIN_CONVERTER_PANEL,
- height=layout.height,
+ self._hierarchy_labels: Dict[HierarchyMode, str] = {
+ HierarchyMode.ROUND_ROBIN: language_manager["main.converter.label.hierarchy_round_robin"],
+ HierarchyMode.STRICT: language_manager["main.converter.label.hierarchy_strict"],
+ }
+ self._settings_handler_tag = compose_tag(TAG_MAIN_CONVERTER_PANEL, SUF_HANDLER_REGISTRY)
+ self._stems_list = GUIStemsList(
+ prefix=PRE_MAIN_CONVERTER_STEMS,
+ layout=stems_layout,
+ language_manager=language_manager,
+ status_bar=status_bar,
+ draggable=True,
+ removable=True,
+ retain_last_row=False,
+ master_checkbox=False,
)
- self._enable_vertical_collapse(initial_collapsed=initial_collapsed)
+
+ super().__init__(tag=TAG_MAIN_CONVERTER_PANEL)
+ self._enable_vertical_collapse(initial_collapsed=initial_collapsed, auto_height=True)
def create_panel(self, parent: str) -> None:
+ self._create_handlers()
with self._collapsible_card(
parent,
self._language_manager["main.converter.label.section"],
glyph=self._glyphs.headers.converter,
width=self.width,
+ no_scrollbar=True,
card_theme=TAG_GLOBAL_THEME_PANEL_EMPHASIS,
):
self._create_action_button()
dpg.add_separator()
+ self._create_controls()
+ self._create_stems_list()
self._create_summary()
+ dpg.add_separator()
self._create_conversion_status()
+ @property
+ def stems_list(self) -> GUIStemsList:
+ """The list the gathered recordings are drawn in, which is what addresses their widgets."""
+ return self._stems_list
+
def is_visible(self) -> bool:
return bool(dpg.get_item_configuration(self.tag)["show"])
def update_view(self, view_model: ConverterViewModel) -> None:
- self._update_visibility(view_model)
self._update_status(view_model)
self._update_paths(view_model)
self._update_controls(view_model)
+ self._update_setup(view_model)
+ self._update_visibility(view_model)
+
+ def _create_handlers(self) -> None:
+ with dpg.item_handler_registry(tag=self._settings_handler_tag):
+ dpg.add_item_deactivated_after_edit_handler(callback=self._on_channel_cap_edited)
def _update_visibility(self, view_model: ConverterViewModel) -> None:
- has_input = view_model.input_path is not None
- dpg.configure_item(TAG_MAIN_CONVERTER_GROUP, show=view_model.subpanel_visible)
- dpg_configure_item(TAG_MAIN_CONVERTER_WINDOW_SUMMARY, show=not view_model.subpanel_visible)
- dpg_configure_item(TAG_MAIN_CONVERTER_TEXT_SUMMARY_HINT, show=not has_input)
- dpg_configure_item(TAG_MAIN_CONVERTER_GROUP_SUMMARY, show=has_input)
+ dpg_configure_item(TAG_MAIN_CONVERTER_GROUP, show=view_model.subpanel_visible)
+ dpg_configure_item(TAG_MAIN_CONVERTER_TEXT_SUMMARY_HINT, show=not view_model.has_input)
+ dpg_configure_item(TAG_MAIN_CONVERTER_GROUP_SUMMARY, show=view_model.has_input)
def _update_status(self, view_model: ConverterViewModel) -> None:
dpg_set_value(TAG_MAIN_CONVERTER_TEXT_STATUS, view_model.status_text)
@@ -143,6 +225,183 @@ def _update_controls(self, view_model: ConverterViewModel) -> None:
show=view_model.other_operation_active and view_model.primary_action == ConverterAction.CONVERT,
)
+ def _create_controls(self) -> None:
+ """The row of choices every conversion carries: stems mode, the cap, and the picking order."""
+ with dpg.group(tag=TAG_MAIN_CONVERTER_GROUP_CONTROLS):
+ dpg.add_checkbox(
+ label=self._language_manager["main.converter.label.stems_mode"],
+ tag=TAG_MAIN_CONVERTER_CHECKBOX_STEMS_MODE,
+ callback=self._on_stems_mode_toggled,
+ )
+ with labeled_field(self._language_manager["main.converter.label.channel_cap"], self._label_width):
+ cap_input = dpg.add_input_int(
+ tag=TAG_MAIN_CONVERTER_INPUT_CHANNEL_CAP,
+ width=self._input_width,
+ min_value=MIN_CHANNEL_CAP,
+ min_clamped=True,
+ max_clamped=True,
+ default_value=len(ChannelName),
+ callback=self._on_channel_cap_edited,
+ )
+ FontRegistry.bind_to_item(cap_input, Font.MONO)
+
+ with labeled_field(self._language_manager["main.converter.label.hierarchy_mode"], self._label_width):
+ dpg.add_combo(
+ items=list(self._hierarchy_labels.values()),
+ tag=TAG_MAIN_CONVERTER_COMBO_HIERARCHY_MODE,
+ width=self._input_width,
+ default_value=self._hierarchy_labels[HierarchyMode.ROUND_ROBIN],
+ callback=self._on_hierarchy_mode_changed,
+ )
+
+ dpg.bind_item_handler_registry(TAG_MAIN_CONVERTER_INPUT_CHANNEL_CAP, self._settings_handler_tag)
+ self._attach_control_tooltips()
+ self._status_bar.bind_to_item(
+ TAG_MAIN_CONVERTER_CHECKBOX_STEMS_MODE,
+ self._language_manager["main.converter.message.status_stems_mode"],
+ )
+
+ def _attach_control_tooltips(self) -> None:
+ for tag, message, tooltip_tag in (
+ (
+ TAG_MAIN_CONVERTER_CHECKBOX_STEMS_MODE,
+ self._language_manager["main.converter.message.stems_mode_tooltip"],
+ TAG_MAIN_CONVERTER_TOOLTIP_STEMS_MODE,
+ ),
+ (
+ TAG_MAIN_CONVERTER_INPUT_CHANNEL_CAP,
+ self._language_manager["main.converter.message.channel_cap_tooltip"],
+ TAG_MAIN_CONVERTER_TOOLTIP_CHANNEL_CAP,
+ ),
+ (
+ TAG_MAIN_CONVERTER_COMBO_HIERARCHY_MODE,
+ self._language_manager["main.converter.message.hierarchy_mode_tooltip"],
+ TAG_MAIN_CONVERTER_TOOLTIP_HIERARCHY_MODE,
+ ),
+ ):
+ show_tooltip(tag, message, tag=tooltip_tag)
+
+ def _create_stems_list(self) -> None:
+ """The recordings gathered so far, under the levels they pick on."""
+ with dpg.group(tag=TAG_MAIN_CONVERTER_WINDOW_STEMS, show=False):
+ hint = dpg.add_text(
+ self._language_manager["main.converter.message.stems_empty_hint"],
+ tag=TAG_MAIN_CONVERTER_TEXT_STEMS_HINT,
+ wrap=0,
+ )
+ FontRegistry.bind_to_item(hint, Font.REGULAR_SMALL)
+ self._stems_list.create(TAG_MAIN_CONVERTER_WINDOW_STEMS)
+
+ self._stems_list.on_channels_changed = self._on_source_channels_changed
+ self._stems_list.on_remove_requested = self._on_source_removed
+ self._stems_list.on_menu_requested = self._show_row_menu
+ self._stems_list.on_dropped_on_row = self._on_dropped_on_source
+ self._stems_list.on_dropped_on_level = self._on_dropped_on_level
+
+ def _update_setup(self, view_model: ConverterViewModel) -> None:
+ dpg_set_value(TAG_MAIN_CONVERTER_CHECKBOX_STEMS_MODE, view_model.stems_mode)
+ dpg_configure_item(
+ TAG_MAIN_CONVERTER_INPUT_CHANNEL_CAP,
+ max_value=view_model.max_channel_cap,
+ enabled=not view_model.is_active,
+ )
+ dpg_set_value(TAG_MAIN_CONVERTER_INPUT_CHANNEL_CAP, view_model.channel_cap)
+ dpg_set_value(
+ TAG_MAIN_CONVERTER_COMBO_HIERARCHY_MODE,
+ self._hierarchy_labels[view_model.hierarchy_mode],
+ )
+ dpg_configure_item(TAG_MAIN_CONVERTER_COMBO_HIERARCHY_MODE, show=view_model.stems_mode)
+ set_tooltip_visible(TAG_MAIN_CONVERTER_TOOLTIP_HIERARCHY_MODE, view_model.stems_mode)
+ dpg_configure_item(TAG_MAIN_CONVERTER_CHECKBOX_STEMS_MODE, enabled=not view_model.is_active)
+ self._update_stems_list(view_model)
+
+ def _update_stems_list(self, view_model: ConverterViewModel) -> None:
+ dpg_configure_item(TAG_MAIN_CONVERTER_WINDOW_STEMS, show=view_model.stems_mode)
+ dpg_configure_item(TAG_MAIN_CONVERTER_TEXT_STEMS_HINT, show=view_model.source_count == 0)
+ self._stems_list.update_view(view_model.stems_list)
+
+ def _on_stems_mode_toggled(self, _sender: Sender, value: bool) -> None:
+ self.call(self.on_stems_mode_changed, value)
+
+ def _on_channel_cap_edited(self, _sender: Sender, _app_data: Any) -> None:
+ self.call(self.on_channel_cap_changed, int(clamp_widget_value(TAG_MAIN_CONVERTER_INPUT_CHANNEL_CAP)))
+
+ def _on_hierarchy_mode_changed(self, _sender: Sender, value: str) -> None:
+ for hierarchy_mode, label in self._hierarchy_labels.items():
+ if label == value:
+ self.call(self.on_hierarchy_mode_changed, hierarchy_mode)
+ return
+
+ def _on_source_channels_changed(self, key: str, channels: FrozenSet[ChannelName]) -> None:
+ self.call(self.on_source_channels_changed, Path(key), channels)
+
+ def _on_source_removed(self, key: str) -> None:
+ self.call(self.on_source_removed, Path(key))
+
+ def _on_dropped_on_source(self, key: str, target_key: str) -> None:
+ self.call(self.on_source_dropped_on_source, Path(key), Path(target_key))
+
+ def _on_dropped_on_level(self, key: str, position: int) -> None:
+ self.call(self.on_source_dropped_on_level, Path(key), position)
+
+ def _show_row_menu(self, key: str) -> None:
+ """Names the moves the row can make, graying out the ones that would change nothing,
+ and offers the recording's own filesystem actions below them."""
+ row = self._stems_list.row(key)
+ if row is None:
+ return
+
+ with context_menu():
+ header = dpg.add_text(row.name)
+ FontRegistry.bind_to_item(header, Font.MONO_BOLD)
+ dpg.add_separator()
+ for element, enabled, callback in self._row_moves(row):
+ dpg.add_menu_item(
+ label=self._label(element),
+ enabled=enabled,
+ callback=callback,
+ )
+
+ add_path_menu_items(self._language_manager, row.path)
+
+ def _row_moves(self, row: StemRowViewModel) -> List[Tuple[ConverterStemMoveElements, bool, VoidCallback]]:
+ path = row.path
+ return [
+ (
+ ConverterStemMoveElements.CONTEXT_MOVE_UP,
+ not row.is_first_on_level,
+ lambda: self.call(self.on_source_moved, path, POSITION_EARLIER),
+ ),
+ (
+ ConverterStemMoveElements.CONTEXT_MOVE_DOWN,
+ not row.is_last_on_level,
+ lambda: self.call(self.on_source_moved, path, POSITION_LATER),
+ ),
+ (
+ ConverterStemMoveElements.CONTEXT_JOIN_ABOVE,
+ row.has_level_above,
+ lambda: self.call(self.on_source_level_joined, path, LEVEL_ABOVE),
+ ),
+ (
+ ConverterStemMoveElements.CONTEXT_JOIN_BELOW,
+ row.has_level_below,
+ lambda: self.call(self.on_source_level_joined, path, LEVEL_BELOW),
+ ),
+ (
+ ConverterStemMoveElements.CONTEXT_ISOLATE,
+ not row.alone_on_level,
+ lambda: self.call(self.on_source_isolated, path),
+ ),
+ (
+ ConverterStemMoveElements.CONTEXT_REMOVE_STEM,
+ True,
+ lambda: self.call(self.on_source_removed, path),
+ ),
+ ]
+
+ def _label(self, element: ConverterStemMoveElements) -> str:
+ return self._language_manager[Page.MAIN, Panel.CONVERTER, TextType.LABEL, element]
+
def _create_action_button(self) -> None:
self._theme_convert = ThemeRegistry.get(TAG_GLOBAL_THEME_PRIMARY_BUTTON)
self._theme_cancel = ThemeRegistry.get(TAG_GLOBAL_THEME_DANGER_BUTTON)
@@ -171,40 +430,35 @@ def _action_status_message(self, *_args: Any, **_kwargs: Any) -> str:
return self._status_action_message
def _create_summary(self) -> None:
- with dpg.child_window(
- tag=TAG_MAIN_CONVERTER_WINDOW_SUMMARY,
- width=-1,
- height=-1,
- border=False,
- ):
- hint = dpg.add_text(
- self._language_manager["main.converter.message.status_empty_hint"],
- tag=TAG_MAIN_CONVERTER_TEXT_SUMMARY_HINT,
+ dpg.add_separator()
+ hint = dpg.add_text(
+ self._language_manager["main.converter.message.status_empty_hint"],
+ tag=TAG_MAIN_CONVERTER_TEXT_SUMMARY_HINT,
+ )
+ FontRegistry.bind_to_item(hint, Font.REGULAR_SMALL)
+ with dpg.group(tag=TAG_MAIN_CONVERTER_GROUP_SUMMARY, show=False):
+ self.input_path_text = GUIPathText(
+ path=None,
+ prefix=self._language_manager["main.converter.message.status_input_label"],
+ tag=TAG_MAIN_CONVERTER_PATH_INPUT_PATH,
+ parent=TAG_MAIN_CONVERTER_GROUP_SUMMARY,
+ color=self._path_colors.default,
+ hover_color=self._path_colors.hover,
+ status_message=self._msg_path,
+ font=Font.REGULAR_SMALL,
+ status_bar=self._status_bar,
+ )
+ self.output_path_text = GUIDestinationPathText(
+ path=None,
+ prefix=self._language_manager["main.converter.message.status_output_label"],
+ tag=TAG_MAIN_CONVERTER_TEXT_OUTPUT_PATH,
+ parent=TAG_MAIN_CONVERTER_GROUP_SUMMARY,
+ color=self._path_colors.default,
+ hover_color=self._path_colors.hover,
+ status_message=self._msg_destination,
+ font=Font.REGULAR_SMALL,
+ status_bar=self._status_bar,
)
- FontRegistry.bind_to_item(hint, Font.REGULAR_SMALL)
- with dpg.group(tag=TAG_MAIN_CONVERTER_GROUP_SUMMARY, show=False):
- self.input_path_text = GUIPathText(
- path=None,
- prefix=self._language_manager["main.converter.message.status_input_label"],
- tag=TAG_MAIN_CONVERTER_PATH_INPUT_PATH,
- parent=TAG_MAIN_CONVERTER_GROUP_SUMMARY,
- color=self._path_colors.default,
- hover_color=self._path_colors.hover,
- status_message=self._msg_path,
- font=Font.REGULAR_SMALL,
- status_bar=self._status_bar,
- )
- self.output_path_text = GUIDestinationPathText(
- path=None,
- prefix=self._language_manager["main.converter.message.status_output_label"],
- tag=TAG_MAIN_CONVERTER_TEXT_OUTPUT_PATH,
- parent=TAG_MAIN_CONVERTER_GROUP_SUMMARY,
- color=self._path_colors.default,
- hover_color=self._path_colors.hover,
- status_message=self._msg_destination,
- font=Font.REGULAR_SMALL,
- status_bar=self._status_bar,
- )
def _create_conversion_status(self) -> None:
with dpg.group(
diff --git a/src/sampletones_application/ui/panels/main/explorer.py b/src/sampletones_application/ui/panels/main/explorer.py
index 45da542df..8d79e3521 100644
--- a/src/sampletones_application/ui/panels/main/explorer.py
+++ b/src/sampletones_application/ui/panels/main/explorer.py
@@ -1,5 +1,5 @@
from pathlib import Path
-from typing import Any, List, Optional, Protocol, Tuple
+from typing import Any, Callable, List, Optional, Protocol, Tuple
import dearpygui.dearpygui as dpg
@@ -24,6 +24,7 @@
from sampletones_application.ui.elements.tree.state import TreeNodeState
from sampletones_application.ui.elements.tree.tags import FileBrowserTags
from sampletones_application.ui.elements.tree.tree import NO_EXPANDED_ROWS
+from sampletones_application.utils.gui.keyboard.modifiers import Modifier, capture_modifiers
from sampletones_application.utils.parallelization.thread import concurrent
from sampletones_core.structures.tree import (
FileSystemNode,
@@ -93,6 +94,9 @@ def __init__(
self.on_wave_file_clicked: Optional[PathCallback] = None
self.on_directory_clicked: Optional[PathCallback] = None
+ self.on_directory_add_requested: Optional[PathCallback] = None
+ self.on_file_add_requested: Optional[PathCallback] = None
+ self.can_add_stems: Optional[Callable[[], bool]] = None
self.on_reconstruct_directory: Optional[PathCallback] = None
self.on_reconstruct_file: Optional[PathCallback] = None
self.on_load_reconstruction: Optional[PathCallback] = None
@@ -174,6 +178,7 @@ def _collect_subtree_specs(
self._pending_specs = []
if self._explorer_logic.has_loaded_children(node.filepath):
for child in node.children:
+ assert isinstance(child, FileSystemNode), "Explorer child is not a FileSystemNode"
self._build_tree_node(
child,
TreeNodeState(
@@ -271,14 +276,27 @@ def _on_file_node_clicked(
case extensions.EXT_FILE_RECONSTRUCTION:
return self._logic.request_autoplay(node)
case suffix if suffix in extensions.EXT_FILES_AUDIO:
- self.call(self.on_wave_file_clicked, node.filepath)
- return self._logic.request_autoplay(node)
+ return self._audio_node_clicked(node)
if mouse_button == dpg.mvMouseButton_Right:
return self._show_file_context_menu(node)
return None
+ def _audio_node_clicked(self, node: FileSystemNode) -> None:
+ """Answers a click on a recording: Ctrl gathers it as a stem, else it becomes the selection.
+
+ Ctrl is the gathering gesture throughout the browser, so it reaches a recording the same
+ way it reaches a folder and does what **Add as stem** does, opening a stems conversion
+ where none is being built. A plain click hands the recording to the converter and plays it.
+ """
+ if Modifier.CTRL in capture_modifiers() and self.query(self.can_add_stems, default=False):
+ self.call(self.on_file_add_requested, node.filepath)
+ return
+
+ self.call(self.on_wave_file_clicked, node.filepath)
+ self._logic.request_autoplay(node)
+
def _on_file_node_double_clicked(
self,
_sender: Sender,
@@ -331,10 +349,19 @@ def _directory_node_clicked(
node: FileSystemNode,
node_tag: str,
) -> None:
+ """Answers a click on a folder: Ctrl offers its recordings as stems, else it opens.
+
+ Ctrl does what **Add folder as stems** does, opening a stems conversion where none is
+ being built. While the converter is busy the folder opens the way a plain click opens it.
+ """
has_content = self._explorer_logic.has_relevant_content(node.filepath)
if not has_content:
return
+ if Modifier.CTRL in capture_modifiers() and self.query(self.can_add_stems, default=False):
+ self.call(self.on_directory_add_requested, node.filepath)
+ return
+
self._toggle_directory_expansion(node, node_tag)
self.call(self.on_directory_clicked, node.filepath)
@@ -403,6 +430,10 @@ def _add_context_menu_file_actions(self, node: FileSystemNode) -> None:
label=self._language_manager["main.explorer.label.context_reconstruct_file"],
callback=lambda: self._context_reconstruct_file(node),
)
+ dpg.add_menu_item(
+ label=self._language_manager["main.explorer.label.context_add_stem"],
+ callback=lambda: self.call(self.on_file_add_requested, node.filepath),
+ )
def _show_file_context_menu(self, node: FileSystemNode) -> None:
if not isinstance(node, FileSystemNode) or node.node_type != NodeType.FILE:
@@ -424,6 +455,10 @@ def _add_context_menu_reconstruction_directory(
label=self._language_manager["main.explorer.label.context_reconstruct_directory"],
callback=lambda: self._context_reconstruct_directory(node),
)
+ dpg.add_menu_item(
+ label=self._language_manager["main.explorer.label.context_add_folder_stems"],
+ callback=lambda: self.call(self.on_directory_add_requested, node.filepath),
+ )
def _add_context_menu_set_directory_items(self, node: FileSystemNode) -> None:
dpg.add_separator()
diff --git a/src/sampletones_application/ui/panels/main/reconstructor.py b/src/sampletones_application/ui/panels/main/reconstructor.py
index 398f686a3..dc24775ce 100644
--- a/src/sampletones_application/ui/panels/main/reconstructor.py
+++ b/src/sampletones_application/ui/panels/main/reconstructor.py
@@ -9,7 +9,7 @@
from sampletones_application.tags.compose import compose_tag
from sampletones_application.tags.general import SUF_HANDLER_REGISTRY
from sampletones_application.tags.main import (
- PRE_MAIN_RECONSTRUCTOR_GENERATOR,
+ PRE_MAIN_RECONSTRUCTOR_CHANNEL,
TAG_MAIN_RECONSTRUCTOR_PANEL,
TAG_MAIN_RECONSTRUCTOR_SLIDER_DRIVE,
)
@@ -28,7 +28,7 @@
)
from sampletones_application.view_model.main.updates import GenerationSettingsUpdate
from sampletones_core.constants.algorithm import MAX_DRIVE
-from sampletones_core.constants.enums import GeneratorName
+from sampletones_core.constants.enums import ChannelName
from sampletones_shared.types.application import Sender
@@ -78,27 +78,27 @@ def _setup_handlers(self) -> None:
dpg.add_item_edited_handler(callback=self._on_parameter_change)
def _create_generator_selection(self) -> None:
- subheader(self._language_manager["main.reconstructor.label.section_generators"])
+ subheader(self._language_manager["main.reconstructor.label.section_channels"])
with dpg.group():
- for generator, label, theme_tag in self._generator_chips():
- checkbox_tag = self._get_generator_checkbox_tag(generator)
+ for channel, label, theme_tag in self._channel_chips():
+ checkbox_tag = self._get_generator_checkbox_tag(channel)
dpg.add_checkbox(
label=label,
- default_value=generator in self._view.generators,
+ default_value=channel in self._view.channels,
tag=checkbox_tag,
callback=self._on_parameter_change,
)
ThemeRegistry.get(theme_tag).bind_to_item(checkbox_tag)
- def _generator_chips(self) -> List[Tuple[GeneratorName, str, str]]:
+ def _channel_chips(self) -> List[Tuple[ChannelName, str, str]]:
return [
(
- generator_name,
- channel_label(self._language_manager, generator_name),
- CHANNEL_THEME_TAGS[generator_name],
+ channel_name,
+ channel_label(self._language_manager, channel_name),
+ CHANNEL_THEME_TAGS[channel_name],
)
- for generator_name in GeneratorName.items()
+ for channel_name in ChannelName.items()
]
def _create_drive_slider(self) -> None:
@@ -131,13 +131,13 @@ def _create_tooltips(self) -> None:
self._language_manager["main.reconstructor.tooltip.tooltip_drive"],
)
- def toggle_generator(self, generator: GeneratorName) -> None:
- """Switches one generator in or out of the set a reconstruction is built from.
+ def toggle_channel(self, channel: ChannelName) -> None:
+ """Switches one channel in or out of the set a reconstruction is built from.
- This is the gesture a click on the generator's checkbox makes, reached by the key the
+ This is the gesture a click on the channel's checkbox makes, reached by the key the
channel answers to, so the panel reports the settings either way.
"""
- checkbox_tag = self._get_generator_checkbox_tag(generator)
+ checkbox_tag = self._get_generator_checkbox_tag(channel)
dpg_set_value(checkbox_tag, not dpg.get_value(checkbox_tag))
self._report_generation_settings()
@@ -145,21 +145,19 @@ def _on_parameter_change(self, _sender: Sender, _app_data: Any) -> None:
self._report_generation_settings()
def _report_generation_settings(self) -> None:
- generators = [
- generator for generator in GeneratorName if dpg.get_value(self._get_generator_checkbox_tag(generator))
- ]
+ channels = [channel for channel in ChannelName if dpg.get_value(self._get_generator_checkbox_tag(channel))]
generation_update = GenerationSettingsUpdate(
drive=float(clamp_widget_value(TAG_MAIN_RECONSTRUCTOR_SLIDER_DRIVE)),
- generators=generators,
+ channels=channels,
)
self.call(self.on_generation_settings_changed, generation_update)
def update_view(self, view_model: ReconstructorPanelViewModel) -> None:
self._view = view_model
dpg.set_value(TAG_MAIN_RECONSTRUCTOR_SLIDER_DRIVE, view_model.drive)
- for generator in GeneratorName:
- dpg_set_value(self._get_generator_checkbox_tag(generator), generator in view_model.generators)
+ for channel in ChannelName:
+ dpg_set_value(self._get_generator_checkbox_tag(channel), channel in view_model.channels)
@staticmethod
- def _get_generator_checkbox_tag(generator: GeneratorName) -> str:
- return compose_tag(PRE_MAIN_RECONSTRUCTOR_GENERATOR, generator.value)
+ def _get_generator_checkbox_tag(channel: ChannelName) -> str:
+ return compose_tag(PRE_MAIN_RECONSTRUCTOR_CHANNEL, channel.value)
diff --git a/src/sampletones_application/ui/panels/reconstruction/audio.py b/src/sampletones_application/ui/panels/reconstruction/audio.py
index 59a65183d..3fd18c9f9 100644
--- a/src/sampletones_application/ui/panels/reconstruction/audio.py
+++ b/src/sampletones_application/ui/panels/reconstruction/audio.py
@@ -7,9 +7,9 @@
from sampletones_application.tags.reconstructions import (
TAG_RECONSTRUCTIONS_RECONSTRUCTION_GROUP_AUDIO_SOURCE,
TAG_RECONSTRUCTIONS_RECONSTRUCTION_PANEL_AUDIO,
- TAG_RECONSTRUCTIONS_RECONSTRUCTION_PATH_ORIGINAL_AUDIO,
TAG_RECONSTRUCTIONS_RECONSTRUCTION_PATH_RECONSTRUCTION_FILE,
TAG_RECONSTRUCTIONS_RECONSTRUCTION_RADIO_AUDIO_SOURCE,
+ TAG_RECONSTRUCTIONS_RECONSTRUCTION_TEXT_NES_FREQUENCY,
)
from sampletones_application.ui.elements.fonts.font import Font
from sampletones_application.ui.elements.fonts.registry import FontRegistry
@@ -18,16 +18,28 @@
from sampletones_application.ui.elements.status import GUIStatusBar
from sampletones_application.utils.gui.dpg import dpg_configure_item, dpg_set_value
from sampletones_application.utils.palette.colors.base import BaseColor
-from sampletones_application.view_model.reconstruction.reconstruction import (
- ReconstructionPathState,
+from sampletones_application.view_model.reconstruction.paths.path import (
ReconstructionPathViewModel,
+)
+from sampletones_application.view_model.reconstruction.paths.state import (
+ ReconstructionPathState,
+)
+from sampletones_application.view_model.reconstruction.reconstruction import (
ReconstructionViewModel,
)
+from sampletones_core.configs.display import format_nes_frequency
from sampletones_core.constants.enums import AudioSourceType
from sampletones_shared.types.application import Sender
class GUIReconstructionAudioPanel(GUIPanel):
+ """Where the reconstruction came from and which of the two waveforms plays.
+
+ The card names the reconstruction's own file and the engine rate it runs at, and offers
+ the choice between the reconstruction and the audio it was built from. The recordings
+ behind that audio are named by the stems card, one row each.
+ """
+
def __init__(
self,
*,
@@ -43,7 +55,6 @@ def __init__(
self._path_status_color = path_status_color
self._reconstruction_file_path: GUIPathText
- self._original_audio_path: GUIPathText
self.on_audio_source_changed: Optional[Callable[[AudioSourceType], None]] = None
@@ -73,16 +84,16 @@ def create_panel(self, parent: str) -> None:
self._create_audio_source_radio_buttons()
dpg.add_separator()
self._create_path_display()
+ self._create_frequency_display()
def update_view(self, view_model: ReconstructionViewModel) -> None:
self._render_path(
self._reconstruction_file_path,
view_model.reconstruction_file,
)
- self._render_path(self._original_audio_path, view_model.original_audio)
-
+ self._render_frequency(view_model.nes_frequency)
dpg_configure_item(
- TAG_RECONSTRUCTIONS_RECONSTRUCTION_GROUP_AUDIO_SOURCE,
+ TAG_RECONSTRUCTIONS_RECONSTRUCTION_RADIO_AUDIO_SOURCE,
enabled=view_model.audio_source_enabled,
)
if not view_model.audio_source_enabled:
@@ -124,20 +135,23 @@ def _create_path_display(self) -> None:
font=Font.REGULAR_SMALL,
status_bar=self._status_bar,
)
- self._original_audio_path = GUIPathText(
- tag=TAG_RECONSTRUCTIONS_RECONSTRUCTION_PATH_ORIGINAL_AUDIO,
- path=None,
- parent=self._body_container,
- color=self._path_colors.default,
- hover_color=self._path_colors.hover,
- status_message=self._msg_path_status,
- prefix=self._language_manager["reconstructions.reconstruction.label.original_audio_label"],
- font=Font.REGULAR_SMALL,
- status_bar=self._status_bar,
- )
-
self._reconstruction_file_path.set_status("", self._path_status_color)
- self._original_audio_path.set_status("", self._path_status_color)
+
+ def _create_frequency_display(self) -> None:
+ """Draws the engine rate as a readout beside its label, monospaced as a figure."""
+ with dpg.group(horizontal=True, parent=self._body_container):
+ label = dpg.add_text(self._language_manager["reconstructions.reconstruction.label.nes_frequency_label"])
+ dpg.add_text("", tag=TAG_RECONSTRUCTIONS_RECONSTRUCTION_TEXT_NES_FREQUENCY)
+
+ FontRegistry.bind_to_item(label, Font.REGULAR_SMALL)
+ FontRegistry.bind_to_item(TAG_RECONSTRUCTIONS_RECONSTRUCTION_TEXT_NES_FREQUENCY, Font.MONO)
+
+ def _render_frequency(self, nes_frequency: Optional[int]) -> None:
+ """States the rate a loaded reconstruction runs at, and stands blank for an empty tab."""
+ dpg_set_value(
+ TAG_RECONSTRUCTIONS_RECONSTRUCTION_TEXT_NES_FREQUENCY,
+ format_nes_frequency(nes_frequency) if nes_frequency is not None else "",
+ )
def _create_audio_source_radio_buttons(self) -> None:
with dpg.group(
@@ -160,7 +174,7 @@ def _create_audio_source_radio_buttons(self) -> None:
)
dpg_configure_item(
- TAG_RECONSTRUCTIONS_RECONSTRUCTION_GROUP_AUDIO_SOURCE,
+ TAG_RECONSTRUCTIONS_RECONSTRUCTION_RADIO_AUDIO_SOURCE,
enabled=False,
)
diff --git a/src/sampletones_application/ui/panels/reconstruction/instruments/config.py b/src/sampletones_application/ui/panels/reconstruction/instruments/config.py
index 76c6e6528..b7f0629a0 100644
--- a/src/sampletones_application/ui/panels/reconstruction/instruments/config.py
+++ b/src/sampletones_application/ui/panels/reconstruction/instruments/config.py
@@ -4,7 +4,7 @@
from sampletones_application.categories.manager import LanguageManager
from sampletones_application.layout.general.colors.feature import FeatureColors
from sampletones_application.utils.palette.colors.base import BaseColor
-from sampletones_core.constants.enums import FeatureKey, LibraryGeneratorName
+from sampletones_core.constants.enums import FeatureKey, GeneratorName
from sampletones_core.features import feature_range, supported_features
@@ -31,7 +31,7 @@ class FeaturePlotConfig:
def make_feature_plot_configs(
feature_colors: FeatureColors,
language_manager: LanguageManager,
-) -> Dict[LibraryGeneratorName, Dict[FeatureKey, FeaturePlotConfig]]:
+) -> Dict[GeneratorName, Dict[FeatureKey, FeaturePlotConfig]]:
labels = _feature_labels(language_manager)
colors = _feature_colors(feature_colors)
return _build_plot_configs(labels, colors)
@@ -58,7 +58,7 @@ def _feature_colors(feature_colors: FeatureColors) -> Dict[FeatureKey, BaseColor
FeatureKey.VOLUME: feature_colors.volume,
FeatureKey.ARPEGGIO: feature_colors.arpeggio,
FeatureKey.PITCH: feature_colors.pitch,
- FeatureKey.HI_PITCH: feature_colors.pitch,
+ FeatureKey.HI_PITCH: feature_colors.hi_pitch,
FeatureKey.DUTY_CYCLE: feature_colors.duty_cycle,
}
@@ -66,16 +66,16 @@ def _feature_colors(feature_colors: FeatureColors) -> Dict[FeatureKey, BaseColor
def _build_plot_configs(
labels: Dict[FeatureKey, str],
colors: Dict[FeatureKey, BaseColor],
-) -> Dict[LibraryGeneratorName, Dict[FeatureKey, FeaturePlotConfig]]:
- configs: Dict[LibraryGeneratorName, Dict[FeatureKey, FeaturePlotConfig]] = {}
- for kind in LibraryGeneratorName:
+) -> Dict[GeneratorName, Dict[FeatureKey, FeaturePlotConfig]]:
+ configs: Dict[GeneratorName, Dict[FeatureKey, FeaturePlotConfig]] = {}
+ for kind in GeneratorName:
configs[kind] = _build_kind_plot_configs(kind, labels, colors)
return configs
def _build_kind_plot_configs(
- kind: LibraryGeneratorName,
+ kind: GeneratorName,
labels: Dict[FeatureKey, str],
colors: Dict[FeatureKey, BaseColor],
) -> Dict[FeatureKey, FeaturePlotConfig]:
@@ -92,7 +92,7 @@ def _build_kind_plot_configs(
def _build_plot_config(
- kind: LibraryGeneratorName,
+ kind: GeneratorName,
feature_key: FeatureKey,
labels: Dict[FeatureKey, str],
colors: Dict[FeatureKey, BaseColor],
diff --git a/src/sampletones_application/ui/panels/reconstruction/instruments/instruments.py b/src/sampletones_application/ui/panels/reconstruction/instruments/instruments.py
index c33aa6d57..ba2f149c7 100644
--- a/src/sampletones_application/ui/panels/reconstruction/instruments/instruments.py
+++ b/src/sampletones_application/ui/panels/reconstruction/instruments/instruments.py
@@ -1,14 +1,23 @@
from functools import partial
-from typing import Any, Callable, Dict, List, Optional, Tuple, cast
+from typing import Any, Callable, Dict, List, Optional, Tuple
import dearpygui.dearpygui as dpg
import numpy as np
-from sampletones_application.categories.context import channel_label, context_label, context_text
+from sampletones_application.categories.context import (
+ channel_label,
+ context_label,
+ context_text,
+ generator_label,
+)
from sampletones_application.categories.elements.global_ import ContextElements
from sampletones_application.categories.hierarchy import TextType
from sampletones_application.categories.manager import LanguageManager
from sampletones_application.categories.pitch import PitchTooltips
+from sampletones_application.constants.instruments import (
+ AUDITION_GENERATOR,
+ INSTRUMENT_CHANNEL,
+)
from sampletones_application.layout.general.colors.feature import FeatureColors
from sampletones_application.layout.graphs import GraphsLayout
from sampletones_application.tags.compose import compose_tag
@@ -35,6 +44,7 @@
SUF_RECONSTRUCTIONS_INSTRUMENTS_WINDOW,
TAG_RECONSTRUCTIONS_INSTRUMENTS_BUTTON_EXPORT_INSTRUMENT,
TAG_RECONSTRUCTIONS_INSTRUMENTS_PANEL,
+ TAG_RECONSTRUCTIONS_INSTRUMENTS_RADIO_AUDITION,
TAG_RECONSTRUCTIONS_INSTRUMENTS_TABS_BAR,
TAG_RECONSTRUCTIONS_INSTRUMENTS_TEXT_SAMPLE_SIZE,
)
@@ -62,21 +72,36 @@
dpg_configure_item,
dpg_set_value,
)
+from sampletones_application.utils.gui.keyboard import (
+ PRIORITY_PANEL,
+ ActivePredicate,
+ KeyEvent,
+ KeyRouter,
+)
+from sampletones_application.utils.gui.keyboard.piano import PIANO_KEYS
from sampletones_application.utils.gui.palette.dpg import dpg_set_palette_color
from sampletones_application.utils.gui.tooltip import show_tooltip
from sampletones_application.view_model.reconstruction.instruments import (
ReconstructionInstrumentsViewModel,
)
-from sampletones_application.view_model.shared.footprint import SampleFootprintViewModel
+from sampletones_application.view_model.shared.footprint import VoiceFootprintViewModel
from sampletones_core.constants.enums import (
+ ChannelName,
FeatureKey,
GeneratorName,
- LibraryGeneratorName,
)
from sampletones_core.exporters import Features
-from sampletones_core.features import GENERATOR_KIND, resting_reference, supported_features
-from sampletones_core.formats.famitracker.specification.sequences import (
- MAX_SEQUENCE_ITEMS,
+from sampletones_core.features import (
+ BEND_FEATURES,
+ CHANNEL_GENERATOR_KIND,
+ resting_reference,
+ supported_features,
+)
+from sampletones_core.features.envelope import Envelope
+from sampletones_core.features.text import format_envelope, parse_envelope
+from sampletones_core.formats.famitracker.sequences.features import (
+ is_shortened,
+ stored_envelope,
)
from sampletones_core.utils.pitch_kind import (
PERIOD_VALUE_KIND,
@@ -88,10 +113,16 @@
from sampletones_shared.types.callback import VoidCallback
from sampletones_shared.utils.arrays import clamp
-OnInstrumentExportCallback = Callable[[GeneratorName], None]
+OnInstrumentExportCallback = Callable[[ChannelName], None]
+OnAuditionCallback = Callable[[int], None]
OnReconstructionInstrumentHoveredCallback = Callable[[Optional[int]], None]
+def _plotted_items(envelope: Envelope[int]) -> np.ndarray:
+ """The values a dimension writes, as the bar plot draws them."""
+ return np.array(envelope.items, dtype=np.int8)
+
+
class GUIReconstructionInstrumentsPanel(GUIPanel):
def __init__(
self,
@@ -102,23 +133,30 @@ def __init__(
layout_graphs: GraphsLayout,
language_manager: LanguageManager,
status_bar: GUIStatusBar,
+ key_router: KeyRouter,
+ tab_active: ActivePredicate,
initial_collapsed: bool = False,
) -> None:
self._language_manager = language_manager
self._status_bar = status_bar
+ self._router = key_router
+ self._tab_active = tab_active
- self.generator_plots: Dict[GeneratorName, Dict[FeatureKey, GUIBarGraph]] = {}
- self._pitch_steppers: Dict[GeneratorName, GUIPitchStepper] = {}
- self._export_buttons: Dict[GeneratorName, GUIButton] = {}
+ self.channel_plots: Dict[ChannelName, Dict[FeatureKey, GUIBarGraph]] = {}
+ self._pitch_steppers: Dict[ChannelName, GUIPitchStepper] = {}
+ self._export_buttons: Dict[ChannelName, GUIButton] = {}
self.tab_bar_tag = TAG_RECONSTRUCTIONS_INSTRUMENTS_TABS_BAR
self.no_data_message_tag = compose_tag(self.tab_bar_tag, SUF_RECONSTRUCTIONS_INSTRUMENTS_NO_DATA_MESSAGE)
self.mouse_item_handler_tag = compose_tag(TAG_RECONSTRUCTIONS_INSTRUMENTS_PANEL, SUF_HANDLER_REGISTRY)
self.sample_size_tag = TAG_RECONSTRUCTIONS_INSTRUMENTS_TEXT_SAMPLE_SIZE
self.sample_size_group_tag = compose_tag(self.sample_size_tag, SUF_GROUP)
+ self.audition_tag = TAG_RECONSTRUCTIONS_INSTRUMENTS_RADIO_AUDITION
+ self.audition_group_tag = compose_tag(self.audition_tag, SUF_GROUP)
self._graphs: Dict[str, GUIBarGraph] = {}
- self._sequence_lengths: Dict[Tuple[GeneratorName, FeatureKey], int] = {}
+ self._sequences: Dict[Tuple[ChannelName, FeatureKey], Envelope[int]] = {}
+ self._audition_open: bool = False
self._pitch_stepper_style = pitch_stepper_style
self._copy_width = copy_width
self._layout_graphs = layout_graphs
@@ -134,9 +172,10 @@ def __init__(
self.on_instrument_export: Optional[OnInstrumentExportCallback] = None
self.on_reconstruction_instrument_hovered: Optional[OnReconstructionInstrumentHoveredCallback] = None
- self.on_pitch_value_changed: Optional[Callable[[GeneratorName, int], None]] = None
- self.on_bar_data_changed: Optional[Callable[[GeneratorName, FeatureKey, np.ndarray], None]] = None
- self.on_raw_data_changed: Optional[Callable[[GeneratorName, FeatureKey, np.ndarray], None]] = None
+ self.on_pitch_value_changed: Optional[Callable[[ChannelName, int], None]] = None
+ self.on_audition_requested: Optional[OnAuditionCallback] = None
+ self.on_audition_generator_changed: Optional[Callable[[GeneratorName], None]] = None
+ self.on_envelope_changed: Optional[Callable[[ChannelName, FeatureKey, Envelope[int]], None]] = None
self._lbl_copy = language_manager["reconstructions.instruments.label.copy_button"]
self._lbl_sample_size = context_label(language_manager, ContextElements.SAMPLE_SIZE)
@@ -147,8 +186,11 @@ def __init__(
language_manager,
language_manager["reconstructions.instruments.template.initial_pitch_tooltip_template"],
)
+ self._channel_labels: Dict[ChannelName, str] = {
+ channel_name: channel_label(language_manager, channel_name) for channel_name in ChannelName.items()
+ }
self._generator_labels: Dict[GeneratorName, str] = {
- generator_name: channel_label(language_manager, generator_name) for generator_name in GeneratorName.items()
+ generator_name: generator_label(language_manager, generator_name) for generator_name in GeneratorName
}
super().__init__(
@@ -177,6 +219,11 @@ def create_panel(self, parent: str) -> None:
self._create_content()
self._setup_mouse_event_handler()
+ self._router.register(
+ self._on_key_pressed,
+ priority=PRIORITY_PANEL,
+ active=self._audition_keys_active,
+ )
def _create_content(self) -> None:
dpg.add_text(
@@ -214,7 +261,7 @@ def _create_size_field(
The figure names how much of the NES data area an export spends, so it reads as
information beside the fields that change: the label column aligns with the stepper
- below it, and the value carries the stepper's own read-only colour and font. A tooltip
+ below it, and the value carries the stepper's own read-only color and font. A tooltip
names the export the figure measures, since the formats spend differently.
"""
with labeled_field(
@@ -232,13 +279,13 @@ def _create_size_field(
tag=compose_tag(value_tag, SUF_TOOLTIP),
)
- def _get_generator_tab_tag(self, generator_name: GeneratorName) -> str:
- return compose_tag(self.tab_bar_tag, generator_name)
+ def _get_generator_tab_tag(self, channel_name: ChannelName) -> str:
+ return compose_tag(self.tab_bar_tag, channel_name)
- def _get_instrument_size_tag(self, generator_name: GeneratorName) -> str:
+ def _get_instrument_size_tag(self, channel_name: ChannelName) -> str:
return compose_tag(
self.tab_bar_tag,
- generator_name,
+ channel_name,
SUF_RECONSTRUCTIONS_INSTRUMENTS_INSTRUMENT_SIZE,
)
@@ -247,80 +294,80 @@ def _get_window_tag(self, tab_tag: str) -> str:
def _get_feature_group_tag(
self,
- generator_name: GeneratorName,
+ channel_name: ChannelName,
feature_key: FeatureKey,
) -> str:
- return compose_tag(self.tab_bar_tag, generator_name, feature_key, SUF_GROUP)
+ return compose_tag(self.tab_bar_tag, channel_name, feature_key, SUF_GROUP)
def _get_feature_text_group_tag(
self,
- generator_name: GeneratorName,
+ channel_name: ChannelName,
feature_key: FeatureKey,
) -> str:
- return compose_tag(self.tab_bar_tag, generator_name, feature_key, SUF_GRAPH_RAW_DATA)
+ return compose_tag(self.tab_bar_tag, channel_name, feature_key, SUF_GRAPH_RAW_DATA)
def _get_feature_text_tag(self, text_group_tag: str) -> str:
return compose_tag(text_group_tag, SUF_TEXT)
def _get_feature_plot_tag(
self,
- generator_name: GeneratorName,
+ channel_name: ChannelName,
feature_key: FeatureKey,
) -> str:
- return compose_tag(self.tab_bar_tag, generator_name, feature_key, SUF_GRAPH)
+ return compose_tag(self.tab_bar_tag, channel_name, feature_key, SUF_GRAPH)
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 _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.
+ def _export_callback(self, channel_name: ChannelName) -> VoidCallback:
+ """The press handler for one channel's export button.
+ the channel is captured in a closure, which carries one.
"""
- return lambda: self.call(self.on_instrument_export, generator_name)
+ return lambda: self.call(self.on_instrument_export, channel_name)
def _create_tabs_for_generators(self) -> None:
- for generator_name in GeneratorName.items():
- self._create_generator_tab(generator_name)
+ for channel_name in ChannelName.items():
+ self._create_generator_tab(channel_name)
def _generator_kind(
self,
- generator_name: GeneratorName,
- ) -> LibraryGeneratorName:
- return GENERATOR_KIND[generator_name]
+ channel_name: ChannelName,
+ ) -> GeneratorName:
+ return CHANNEL_GENERATOR_KIND[channel_name]
def _generator_features(
self,
- generator_name: GeneratorName,
+ channel_name: ChannelName,
) -> List[FeatureKey]:
- return supported_features(self._generator_kind(generator_name))
+ return supported_features(self._generator_kind(channel_name))
- def _feature_plot_config(self, generator_name: GeneratorName, feature_key: FeatureKey) -> FeaturePlotConfig:
- return self._feature_plot_configs[self._generator_kind(generator_name)][feature_key]
+ def _feature_plot_config(self, channel_name: ChannelName, feature_key: FeatureKey) -> FeaturePlotConfig:
+ return self._feature_plot_configs[self._generator_kind(channel_name)][feature_key]
- def _create_generator_tab(self, generator_name: GeneratorName) -> None:
- tab_tag = self._get_generator_tab_tag(generator_name)
+ def _create_generator_tab(self, channel_name: ChannelName) -> None:
+ tab_tag = self._get_generator_tab_tag(channel_name)
window_tag = self._get_window_tag(tab_tag)
with dpg.tab(
- label=self._generator_labels[generator_name],
+ label=self._channel_labels[channel_name],
tag=tab_tag,
parent=self.tab_bar_tag,
show=False,
):
- self.generator_plots[generator_name] = {}
+ self.channel_plots[channel_name] = {}
button_tag = compose_tag(TAG_RECONSTRUCTIONS_INSTRUMENTS_BUTTON_EXPORT_INSTRUMENT, tab_tag)
- self._export_buttons[generator_name] = GUIButton(
+ self._export_buttons[channel_name] = GUIButton(
tag=button_tag,
parent=tab_tag,
label=self._language_manager["reconstructions.instruments.label.export_instrument_button"],
width=-1,
- callback=self._export_callback(generator_name),
+ callback=self._export_callback(channel_name),
)
self._status_bar.bind_to_item(
button_tag,
self._language_manager["reconstructions.instruments.message.status_export_instrument"].format(
- generator=self._generator_labels[generator_name]
+ channel=self._channel_labels[channel_name]
),
)
@@ -329,7 +376,7 @@ def _create_generator_tab(self, generator_name: GeneratorName) -> None:
parent=tab_tag,
height=-1,
):
- self._create_generator_content(generator_name, window_tag)
+ self._create_generator_content(channel_name, window_tag)
ThemeRegistry.get(TAG_GLOBAL_THEME_PANEL_INSTRUMENT).bind_to_item(window_tag)
@@ -337,41 +384,44 @@ def _create_generator_tab(self, generator_name: GeneratorName) -> None:
def _create_generator_content(
self,
- generator_name: GeneratorName,
+ channel_name: ChannelName,
window_tag: str,
) -> None:
- initial_pitch = self._default_initial_pitch(generator_name)
+ initial_pitch = self._default_initial_pitch(channel_name)
self._create_size_field(
self._lbl_instrument_size,
- self._get_instrument_size_tag(generator_name),
+ self._get_instrument_size_tag(channel_name),
window_tag,
)
- self._create_pitch_stepper(generator_name, initial_pitch, window_tag)
- self._create_generator_feature_displays(generator_name, window_tag)
+ self._create_pitch_stepper(channel_name, initial_pitch, window_tag)
+ if channel_name is INSTRUMENT_CHANNEL:
+ self._create_audition_selector(window_tag)
+
+ self._create_generator_feature_displays(channel_name, window_tag)
- def _default_initial_pitch(self, generator_name: GeneratorName) -> int:
- return resting_reference(generator_name)
+ def _default_initial_pitch(self, channel_name: ChannelName) -> int:
+ return resting_reference(channel_name)
def _create_generator_feature_displays(
self,
- generator_name: GeneratorName,
+ channel_name: ChannelName,
window_tag: str,
) -> None:
- for feature_key in self._generator_features(generator_name):
+ for feature_key in self._generator_features(channel_name):
self._add_generator_feature_display(
- generator_name,
+ channel_name,
feature_key,
window_tag,
)
def _add_generator_feature_display(
self,
- generator_name: GeneratorName,
+ channel_name: ChannelName,
feature_key: FeatureKey,
window_tag: str,
) -> None:
feature_group_tag = self._get_feature_group_tag(
- generator_name,
+ channel_name,
feature_key,
)
with dpg.group(
@@ -379,31 +429,30 @@ def _add_generator_feature_display(
parent=window_tag,
):
dpg.add_separator(parent=window_tag)
- feature_data_array = np.empty(0, dtype=np.int8)
plot = self._create_feature_display(
- generator_name,
+ channel_name,
feature_key,
- feature_data_array,
+ Envelope[int](),
feature_group_tag,
)
- self.generator_plots[generator_name][feature_key] = plot
+ self.channel_plots[channel_name][feature_key] = plot
def _apply_pitch_display(
self,
- generator_name: GeneratorName,
+ channel_name: ChannelName,
value: int,
) -> None:
- stepper = self._pitch_steppers.get(generator_name)
+ stepper = self._pitch_steppers.get(channel_name)
if stepper is not None:
stepper.set_value(value)
def _update_generator_plot(
self,
- generator_name: GeneratorName,
+ channel_name: ChannelName,
feature_key: FeatureKey,
data: np.ndarray,
) -> None:
- plots = self.generator_plots.get(generator_name)
+ plots = self.channel_plots.get(channel_name)
if plots is None:
return
@@ -411,10 +460,10 @@ def _update_generator_plot(
if plot is None:
return
- config = self._feature_plot_config(generator_name, feature_key)
+ config = self._feature_plot_config(channel_name, feature_key)
self._configure_plot_data(
plot,
- generator_name,
+ channel_name,
feature_key,
config,
data,
@@ -422,76 +471,169 @@ def _update_generator_plot(
def _update_raw_data_text(
self,
- generator_name: GeneratorName,
+ channel_name: ChannelName,
feature_key: FeatureKey,
- data: np.ndarray,
+ envelope: Envelope[int],
) -> None:
text_group_tag = self._get_feature_text_group_tag(
- generator_name,
+ channel_name,
feature_key,
)
raw_data_tag = self._get_feature_text_tag(text_group_tag)
- raw_data_text = self._format_data(data)
- dpg_set_value(raw_data_tag, raw_data_text)
- self._apply_input_theme(generator_name, feature_key, len(data))
+ dpg_set_value(raw_data_tag, format_envelope(envelope))
+ self._show_sequence(channel_name, feature_key, envelope)
def update_view(
self,
view_model: ReconstructionInstrumentsViewModel,
) -> None:
- """Shows a tab per channel, marking the ones standing by.
-
- Every channel is editable for as long as a reconstruction is open, so writing an
- envelope into a channel standing by is what puts it in play. A muted tab label and a
- withheld export say which channels are there.
+ """Shows what the panel has in front of it, marking the channels standing by.
+
+ A reconstruction shows a tab per channel, and every channel is editable for as long as it
+ is open, so writing an envelope into a channel standing by is what puts it in play; a
+ muted tab label and a withheld export say which channels are there. An instrument is one
+ set every channel reads, so it shows a single tab under its own name, and the audition
+ takes the pitch stepper's place: a row states the note a hand-written voice sounds at, and
+ what the panel offers instead is the generator to hear it on.
"""
- is_loaded = view_model.reconstruction_loaded
- dpg_configure_item(self.no_data_message_tag, show=not is_loaded)
- dpg_configure_item(self.tab_bar_tag, show=is_loaded)
- dpg_configure_item(self.sample_size_group_tag, show=is_loaded)
- self._update_sizes(view_model.footprint)
-
- for generator_name in GeneratorName.items():
- tab_tag = self._get_generator_tab_tag(generator_name)
- dpg_configure_item(tab_tag, show=is_loaded)
+ instrument = view_model.instrument
+ is_open = view_model.is_open
+ dpg_configure_item(self.no_data_message_tag, show=not is_open)
+ dpg_configure_item(self.tab_bar_tag, show=is_open)
+ dpg_configure_item(self.sample_size_group_tag, show=is_open)
+ self._update_sizes(view_model.footprint, shows_one_instrument=instrument is not None)
+ self._show_pitch_steppers(shown=instrument is None)
+ self._show_audition_selector(shown=instrument is not None)
+
+ for channel_name in ChannelName.items():
+ tab_tag = self._get_generator_tab_tag(channel_name)
+ shown = channel_name is INSTRUMENT_CHANNEL if instrument is not None else view_model.reconstruction_loaded
+ dpg_configure_item(tab_tag, show=shown)
+ if instrument is not None and channel_name is INSTRUMENT_CHANNEL:
+ dpg_configure_item(tab_tag, label=instrument.name)
+ else:
+ dpg_configure_item(tab_tag, label=self._channel_labels[channel_name])
+
self._apply_playing_state(
- generator_name,
- generator_name in view_model.playing_generators,
+ channel_name,
+ channel_name in view_model.playing_channels,
)
+ def _show_pitch_steppers(self, *, shown: bool) -> None:
+ """Offers the pitch a channel measures its arpeggio against, where a reader may move it.
+
+ A conversion states the value it found for each channel and moving it rebuilds that
+ channel's frames. An instrument is placed by a row that states the note itself, so the
+ value it stores for an export stands as it is and the stepper stays out of the way.
+ """
+ for stepper in self._pitch_steppers.values():
+ stepper.set_shown(shown)
+
+ def _create_audition_selector(self, window_tag: str) -> None:
+ """Offers the generator a hand-written voice is heard on, in the pitch stepper's column.
+
+ An instrument is one set of envelopes every generator reads what it can of, so hearing it
+ means choosing which one reads it. The choice belongs to the reader listening rather than
+ to the voice, so it stays on the panel and reaches no document.
+ """
+ with dpg.group(
+ tag=self.audition_group_tag,
+ parent=window_tag,
+ show=False,
+ ):
+ with labeled_field(
+ self._language_manager["reconstructions.instruments.label.audition"],
+ self._pitch_stepper_style.dimensions.label_width,
+ parent=self.audition_group_tag,
+ ):
+ dpg.add_radio_button(
+ items=[self._generator_labels[generator_name] for generator_name in GeneratorName],
+ tag=self.audition_tag,
+ default_value=self._generator_labels[AUDITION_GENERATOR],
+ callback=self._on_audition_generator_changed,
+ horizontal=True,
+ )
+ FontRegistry.bind_to_item(self.audition_tag, Font.REGULAR_SMALL)
+
+ self._status_bar.bind_to_item(
+ self.audition_tag,
+ self._language_manager["reconstructions.instruments.message.status_audition"],
+ )
+ show_tooltip(
+ self.audition_tag,
+ self._language_manager["reconstructions.instruments.tooltip.audition"],
+ tag=compose_tag(self.audition_tag, SUF_TOOLTIP),
+ )
+
+ def _on_audition_generator_changed(self, _sender: Sender, app_data: str) -> None:
+ self.call(
+ self.on_audition_generator_changed,
+ next(generator_name for generator_name, label in self._generator_labels.items() if label == app_data),
+ )
+
+ def _show_audition_selector(self, *, shown: bool) -> None:
+ """Offers the audition while a hand-written voice is open, which is the voice it sounds."""
+ self._audition_open = shown
+ dpg_configure_item(self.audition_group_tag, show=shown)
+
+ def _audition_keys_active(self) -> bool:
+ """Whether a note key sounds the instrument the panel has in front of it.
+
+ The keys reach an instrument alone, since a recording plays the audio it was made from,
+ and only while the Reconstructions tab is in front. A field being typed into keeps its own
+ characters, so a sequence entered by hand types letters rather than sounding notes.
+ """
+ return self._audition_open and self._tab_active() and not self._router.is_field_focused
+
+ def _on_key_pressed(self, event: KeyEvent) -> bool:
+ """Sounds the open instrument at the note a piano key names, reporting whether it did."""
+ semitone = PIANO_KEYS.get(event.key)
+ if semitone is None:
+ return False
+
+ self.call(self.on_audition_requested, semitone)
+ return True
+
def _apply_playing_state(
self,
- generator_name: GeneratorName,
+ channel_name: ChannelName,
is_playing: bool,
) -> None:
"""Marks one channel's tab as playing or standing by.
- The muted theme reaches the tab label alone; the tab's body carries its own text colour,
+ The muted theme reaches the tab label alone; the tab's body carries its own text color,
so a channel standing by stays as readable to edit as one that plays.
"""
theme_tag = TAG_GLOBAL_THEME_INSTRUMENT_TABS if is_playing else TAG_GLOBAL_THEME_INSTRUMENT_TABS_MUTED
- ThemeRegistry.get(theme_tag).bind_to_item(self._get_generator_tab_tag(generator_name))
+ ThemeRegistry.get(theme_tag).bind_to_item(self._get_generator_tab_tag(channel_name))
- export_button = self._export_buttons.get(generator_name)
+ export_button = self._export_buttons.get(channel_name)
if export_button is not None:
export_button.set_enabled(is_playing)
def _update_sizes(
self,
- footprint: Optional[SampleFootprintViewModel],
+ footprint: Optional[VoiceFootprintViewModel],
+ *,
+ shows_one_instrument: bool,
) -> None:
- """Writes the byte figures the loaded reconstruction occupies, the sample's and each channel's.
+ """Writes the byte figures the voice in front of the panel occupies.
- A channel standing by is written by no export, so it reads as the nothing it costs.
+ A reconstruction states its own total and a figure per channel, and a channel standing by
+ is written by no export, so it reads as the nothing it costs. Every channel reaches the same
+ instrument, so the tab it is shown under carries the whole figure.
"""
if footprint is None:
return
dpg_set_value(self.sample_size_tag, self._format_size(footprint.total_bytes))
- for generator_name in GeneratorName.items():
- instrument_bytes = footprint.bytes_for(generator_name)
+ for channel_name in ChannelName.items():
+ instrument_bytes = footprint.bytes_for(channel_name)
+ if shows_one_instrument and channel_name is INSTRUMENT_CHANNEL:
+ instrument_bytes = footprint.total_bytes
+
dpg_set_value(
- self._get_instrument_size_tag(generator_name),
+ self._get_instrument_size_tag(channel_name),
self._format_size(instrument_bytes if instrument_bytes is not None else 0),
)
@@ -500,67 +642,65 @@ def _format_size(self, byte_count: int) -> str:
def update_feature_data(
self,
- generators: Optional[Dict[GeneratorName, Features]],
+ generators: Optional[Dict[ChannelName, Features]],
) -> None:
if generators is None:
return
- for generator_name in GeneratorName.items():
- generator_features = generators.get(generator_name)
+ for channel_name in ChannelName.items():
+ generator_features = generators.get(channel_name)
if generator_features is None:
continue
self._update_generator_feature_data(
- generator_name,
+ channel_name,
generator_features,
)
def _update_generator_feature_data(
self,
- generator_name: GeneratorName,
+ channel_name: ChannelName,
generator_features: Features,
) -> None:
- initial_pitch = cast(int, generator_features[FeatureKey.INITIAL_PITCH])
- self._apply_pitch_display(generator_name, initial_pitch)
+ initial_pitch = generator_features.initial_pitch
+ self._apply_pitch_display(channel_name, initial_pitch)
- for feature_key in self._generator_features(generator_name):
+ for feature_key in self._generator_features(channel_name):
self._update_generator_feature_display(
- generator_name,
+ channel_name,
generator_features,
feature_key,
)
def _update_generator_feature_display(
self,
- generator_name: GeneratorName,
+ channel_name: ChannelName,
generator_features: Features,
feature_key: FeatureKey,
) -> None:
- feature = self._feature_array(generator_features, feature_key)
- self._update_generator_plot(generator_name, feature_key, feature)
- self._update_raw_data_text(generator_name, feature_key, feature)
+ envelope = self._feature_envelope(generator_features, feature_key)
+ self._update_generator_plot(channel_name, feature_key, _plotted_items(envelope))
+ self._update_raw_data_text(channel_name, feature_key, envelope)
- def _feature_array(
+ def _feature_envelope(
self,
generator_features: Features,
feature_key: FeatureKey,
- ) -> np.ndarray:
- feature = cast(Optional[np.ndarray], generator_features.get(feature_key))
- if feature is None:
- return np.array([], dtype=np.int8)
- return feature
+ ) -> Envelope[int]:
+ envelope = generator_features.envelopes.get(feature_key)
+ return envelope if envelope is not None else Envelope[int]()
- def _pitch_kind(self, generator_name: GeneratorName) -> PitchValueKind:
- return PERIOD_VALUE_KIND if generator_name == GeneratorName.NOISE else PITCH_VALUE_KIND
+ def _pitch_kind(self, channel_name: ChannelName) -> PitchValueKind:
+ return PERIOD_VALUE_KIND if channel_name == ChannelName.NOISE else PITCH_VALUE_KIND
def _create_pitch_stepper(
self,
- generator_name: GeneratorName,
+ channel_name: ChannelName,
initial_pitch: int,
parent: str,
) -> None:
- is_noise = generator_name == GeneratorName.NOISE
- kind = self._pitch_kind(generator_name)
+ is_noise = channel_name == ChannelName.NOISE
+ kind = self._pitch_kind(channel_name)
stepper = GUIPitchStepper(
tag=parent,
parent=parent,
@@ -584,16 +724,16 @@ def _create_pitch_stepper(
)
stepper.on_value_changed = partial(
self._on_pitch_value_changed,
- generator_name,
+ channel_name,
)
- self._pitch_steppers[generator_name] = stepper
+ self._pitch_steppers[channel_name] = stepper
def _on_pitch_value_changed(
self,
- generator_name: GeneratorName,
+ channel_name: ChannelName,
value: int,
) -> None:
- self.call(self.on_pitch_value_changed, generator_name, value)
+ self.call(self.on_pitch_value_changed, channel_name, value)
def _on_mouse_move(self, _sender: Sender, _app_data: Tuple[int, int]) -> None:
tab = dpg.get_value(self.tab_bar_tag)
@@ -608,26 +748,26 @@ def _on_mouse_move(self, _sender: Sender, _app_data: Tuple[int, int]) -> None:
def _create_feature_display(
self,
- generator_name: GeneratorName,
+ channel_name: ChannelName,
feature_key: FeatureKey,
- data: np.ndarray,
+ envelope: Envelope[int],
parent: str,
) -> GUIBarGraph:
- config = self._feature_plot_config(generator_name, feature_key)
+ config = self._feature_plot_config(channel_name, feature_key)
plot = self._add_bar_plot(
parent,
config,
- data,
- generator_name,
+ _plotted_items(envelope),
+ channel_name,
feature_key,
)
self._add_raw_data_text(
parent,
- generator_name,
+ channel_name,
feature_key,
config,
plot,
- data,
+ envelope,
)
return plot
@@ -656,10 +796,10 @@ def _add_bar_plot(
parent: str,
config: FeaturePlotConfig,
data: np.ndarray,
- generator_name: GeneratorName,
+ channel_name: ChannelName,
feature_key: FeatureKey,
) -> GUIBarGraph:
- plot_tag = self._get_feature_plot_tag(generator_name, feature_key)
+ plot_tag = self._get_feature_plot_tag(channel_name, feature_key)
y_min, y_max, _ = self._calculate_plot_limits(config, data)
plot = GUIBarGraph(
tag=plot_tag,
@@ -677,7 +817,7 @@ def _add_bar_plot(
self._configure_plot_data(
plot,
- generator_name,
+ channel_name,
feature_key,
config,
data,
@@ -688,15 +828,15 @@ def _add_bar_plot(
def _configure_plot_data(
self,
plot: GUIBarGraph,
- generator_name: GeneratorName,
+ channel_name: ChannelName,
feature_key: FeatureKey,
config: FeaturePlotConfig,
data: np.ndarray,
) -> None:
- self._load_plot_data(plot, generator_name, feature_key, config, data)
+ self._load_plot_data(plot, channel_name, feature_key, config, data)
plot.set_callbacks(
on_bar_point_clicked=lambda data: self._on_bar_point_clicked(
- generator_name,
+ channel_name,
feature_key,
data,
plot.plot_tag,
@@ -706,14 +846,16 @@ def _configure_plot_data(
def _on_bar_point_clicked(
self,
- generator_name: GeneratorName,
+ channel_name: ChannelName,
feature_key: FeatureKey,
data: np.ndarray,
plot_tag: str,
) -> None:
+ envelope = self._standing_sequence(channel_name, feature_key).with_items(tuple(int(value) for value in data))
raw_data_tag = compose_tag(plot_tag, SUF_GRAPH_RAW_DATA)
- dpg_set_value(raw_data_tag, self._format_data(data))
- self.call(self.on_bar_data_changed, generator_name, feature_key, data)
+ dpg_set_value(raw_data_tag, format_envelope(envelope))
+ self._show_sequence(channel_name, feature_key, envelope)
+ self.call(self.on_envelope_changed, channel_name, feature_key, envelope)
def _on_bar_point_hovered(
self,
@@ -731,17 +873,17 @@ def _on_bar_point_hovered(
def _add_raw_data_text(
self,
parent: str,
- generator_name: GeneratorName,
+ channel_name: ChannelName,
feature_key: FeatureKey,
config: FeaturePlotConfig,
plot: GUIBarGraph,
- data: np.ndarray,
+ envelope: Envelope[int],
) -> None:
text_group_tag = self._get_feature_text_group_tag(
- generator_name,
+ channel_name,
feature_key,
)
- raw_data_text = self._format_data(data)
+ raw_data_text = format_envelope(envelope)
raw_data_tag = self._get_feature_text_tag(text_group_tag)
copy_button_tag = compose_tag(text_group_tag, SUF_BUTTON_COPY)
@@ -750,8 +892,9 @@ def _add_raw_data_text(
tag=copy_button_tag,
label=self._lbl_copy,
width=self._copy_width,
- callback=lambda: self._on_copy_button_clicked(
- raw_data_text,
+ callback=self._copy_callback(
+ channel_name,
+ feature_key,
copy_button_tag,
),
)
@@ -765,7 +908,7 @@ def _add_raw_data_text(
decimal=False,
callback=self._parse_raw_data_input,
user_data=(
- generator_name,
+ channel_name,
feature_key,
config,
plot,
@@ -779,86 +922,106 @@ def _add_raw_data_text(
)
self._status_bar.bind_to_item(
raw_data_tag,
- partial(self._sequence_status_message, generator_name, feature_key),
+ partial(self._sequence_status_message, channel_name, feature_key),
+ )
+ self._explain_bend(raw_data_tag, feature_key)
+
+ def _explain_bend(self, raw_data_tag: str, feature_key: FeatureKey) -> None:
+ """Says what one item of a bend dimension is worth, since that follows the note it bends.
+
+ A divider step spans well under a cent at the lowest notes and a whole semitone at the
+ highest, so the axis a bend is drawn on states the range a sequence stores rather than the
+ distance a value covers.
+ """
+ if feature_key not in BEND_FEATURES:
+ return
+
+ show_tooltip(
+ raw_data_tag,
+ self._language_manager[
+ (
+ "reconstructions.instruments.tooltip.hi_pitch_bend"
+ if feature_key is FeatureKey.HI_PITCH
+ else "reconstructions.instruments.tooltip.pitch_bend"
+ )
+ ],
)
def _sequence_status_message(
self,
- generator_name: GeneratorName,
+ channel_name: ChannelName,
feature_key: FeatureKey,
*_args: Any,
**_kwargs: Any,
) -> str:
- """Describes the sequence input, naming the export limit once a sequence passes it."""
- item_count = self._sequence_lengths.get((generator_name, feature_key), 0)
- if item_count > MAX_SEQUENCE_ITEMS:
+ """Describes the sequence input, naming what a FamiTracker file holds of an over-long one."""
+ envelope = self._standing_sequence(channel_name, feature_key)
+ if is_shortened(feature_key, envelope):
return self._language_manager["reconstructions.instruments.message.status_sequence_too_long"].format(
instrument_feature=feature_key.capitalized,
- items=item_count,
- limit=MAX_SEQUENCE_ITEMS,
+ items=len(envelope.items),
+ limit=len(stored_envelope(feature_key, envelope).items),
)
return self._language_manager["reconstructions.instruments.message.status_sequence"].format(
instrument_feature=feature_key.capitalized
)
- def _apply_input_theme(
+ def _standing_sequence(
+ self,
+ channel_name: ChannelName,
+ feature_key: FeatureKey,
+ ) -> Envelope[int]:
+ """The dimension this input shows, which is what an edit of its values starts from."""
+ return self._sequences.get((channel_name, feature_key), Envelope[int]())
+
+ def _show_sequence(
self,
- generator_name: GeneratorName,
+ channel_name: ChannelName,
feature_key: FeatureKey,
- item_count: int,
+ envelope: Envelope[int],
) -> None:
- """Colours the sequence input by how a FamiTracker export treats its length.
+ """Holds the dimension the input now shows, colored by how a FamiTracker export treats its length.
- A sequence longer than ``MAX_SEQUENCE_ITEMS`` exports its opening items, so the
- input carries the warning colour to show which part of the envelope reaches a
- FamiTracker file.
+ A sequence a FamiTracker file holds only part of carries the warning color, so which
+ dimensions reach that file whole is visible before an export.
"""
- self._sequence_lengths[(generator_name, feature_key)] = item_count
- text_group_tag = self._get_feature_text_group_tag(generator_name, feature_key)
+ self._sequences[(channel_name, feature_key)] = envelope
+ text_group_tag = self._get_feature_text_group_tag(channel_name, feature_key)
raw_data_tag = self._get_feature_text_tag(text_group_tag)
- theme = self.warning_input_theme if item_count > MAX_SEQUENCE_ITEMS else self.theme
+ theme = self.warning_input_theme if is_shortened(feature_key, envelope) else self.theme
theme.bind_to_item(raw_data_tag)
def _parse_raw_data_input(
self,
sender: Sender,
app_data: str,
- user_data: Tuple[GeneratorName, FeatureKey, FeaturePlotConfig, GUIBarGraph],
+ user_data: Tuple[ChannelName, FeatureKey, FeaturePlotConfig, GUIBarGraph],
) -> None:
- generator_name, feature_key, config, plot = user_data
- data_range = config.data_range if config.data_range is not None else (-128, 127)
-
+ channel_name, feature_key, config, plot = user_data
try:
- raw_data_items = app_data.strip().split()
- raw_data = np.array(
- [clamp(int(value), *data_range) for value in raw_data_items],
- dtype=np.int8,
- )
+ typed = parse_envelope(app_data)
except ValueError:
- logger.error(f"Invalid {generator_name.name} data input for {feature_key.name}: {app_data}")
+ logger.error(f"Invalid {channel_name.name} data input for {feature_key.name}: {app_data}")
self.invalid_input_theme.bind_to_item(sender)
return
- self._apply_input_theme(generator_name, feature_key, len(raw_data))
- dpg.set_value(sender, self._format_data(raw_data))
- self.call(self.on_raw_data_changed, generator_name, feature_key, raw_data)
- self._load_plot_data(plot, generator_name, feature_key, config, raw_data)
-
- def _format_data(self, data: np.ndarray) -> str:
- string_data = [str(clamp(int(value), -128, 127)) for value in data]
- return " ".join(string_data)
+ envelope = typed.with_items(tuple(clamp(item, *config.data_range) for item in typed.items))
+ self._show_sequence(channel_name, feature_key, envelope)
+ dpg.set_value(sender, format_envelope(envelope))
+ self.call(self.on_envelope_changed, channel_name, feature_key, envelope)
+ self._load_plot_data(plot, channel_name, feature_key, config, _plotted_items(envelope))
def _load_plot_data(
self,
plot: GUIBarGraph,
- generator_name: GeneratorName,
+ channel_name: ChannelName,
feature_key: FeatureKey,
config: FeaturePlotConfig,
data: np.ndarray,
) -> None:
_, _, y_ticks = self._calculate_plot_limits(config, data)
- name = f"{generator_name.capitalize()}: {feature_key.capitalized}"
+ name = f"{channel_name.capitalize()}: {feature_key.capitalized}"
plot.load_data(
data=data,
name=name,
@@ -866,6 +1029,22 @@ def _load_plot_data(
y_ticks=y_ticks,
)
+ def _copy_callback(
+ self,
+ channel_name: ChannelName,
+ feature_key: FeatureKey,
+ button_tag: str,
+ ) -> VoidCallback:
+ """The press handler for one dimension's copy button.
+
+ The button stands beside its input from the moment the tab is built, so the press reads
+ the dimension the input shows then, written out as a reader would type it.
+ """
+ return lambda: self._on_copy_button_clicked(
+ format_envelope(self._standing_sequence(channel_name, feature_key)),
+ button_tag,
+ )
+
def _on_copy_button_clicked(self, text: str, button_tag: str) -> None:
copy_to_clipboard(
text,
diff --git a/src/sampletones_application/ui/panels/reconstruction/plot.py b/src/sampletones_application/ui/panels/reconstruction/plot.py
index d19132337..3d667d71f 100644
--- a/src/sampletones_application/ui/panels/reconstruction/plot.py
+++ b/src/sampletones_application/ui/panels/reconstruction/plot.py
@@ -4,12 +4,13 @@
from sampletones_application.categories.context import channel_label
from sampletones_application.categories.manager import LanguageManager
+from sampletones_application.layout.general.colors.channel import ChannelColors
from sampletones_application.layout.graphs import GraphsLayout
from sampletones_application.tags.compose import compose_tag
from sampletones_application.tags.reconstructions import (
- PRE_RECONSTRUCTION_GENERATOR,
+ PRE_RECONSTRUCTION_CHANNEL,
SUF_RECONSTRUCTIONS_RECONSTRUCTION_AUTOSCALE,
- TAG_RECONSTRUCTIONS_RECONSTRUCTION_GROUP_GENERATORS,
+ TAG_RECONSTRUCTIONS_RECONSTRUCTION_GROUP_CHANNELS,
TAG_RECONSTRUCTIONS_RECONSTRUCTION_PANEL_PLOT,
TAG_RECONSTRUCTIONS_RECONSTRUCTION_PANEL_RECONSTRUCTION_WAVEFORM,
)
@@ -25,8 +26,11 @@
from sampletones_application.view_model.reconstruction.reconstruction import (
ReconstructionViewModel,
)
+from sampletones_application.view_model.reconstruction.waveform import (
+ InstrumentWaveformViewModel,
+)
from sampletones_application.view_model.shared.waveform_data import WaveformData
-from sampletones_core.constants.enums import AudioSourceType, GeneratorName
+from sampletones_core.constants.enums import AudioSourceType, ChannelName
from sampletones_shared.types.application import Sender
from sampletones_shared.types.callback import MessageCallback
@@ -36,18 +40,20 @@ def __init__(
self,
*,
layout_graphs: GraphsLayout,
+ channel_colors: ChannelColors,
language_manager: LanguageManager,
status_bar: GUIStatusBar,
initial_collapsed: bool = False,
) -> None:
self._layout_graphs = layout_graphs
+ self._channel_colors = channel_colors
self._status_bar = status_bar
self._language_manager = language_manager
self.waveform_display: GUIWaveformGraph
self._frame_length: Optional[int] = None
- self.on_generators_changed: Optional[Callable[[List[GeneratorName]], None]] = None
+ self.on_channels_changed: Optional[Callable[[List[ChannelName]], None]] = None
self.autoscale_tag = compose_tag(
TAG_RECONSTRUCTIONS_RECONSTRUCTION_PANEL_PLOT, SUF_RECONSTRUCTIONS_RECONSTRUCTION_AUTOSCALE
@@ -71,7 +77,7 @@ def create_panel(self, parent: str) -> None:
):
self._create_autoscale_checkbox()
self._create_waveform_display()
- self._create_generator_checkboxes()
+ self._create_channel_checkboxes()
self._create_tooltips()
def update_view(self, view_model: ReconstructionViewModel) -> None:
@@ -80,10 +86,10 @@ def update_view(self, view_model: ReconstructionViewModel) -> None:
The channels an edit puts in play arrive already selected and one switched off by hand
arrives as it was left, so the boxes report what plays without overruling a choice.
"""
- for generator_name in GeneratorName:
- tag = self._get_generator_checkbox_tag(generator_name)
- is_playing = generator_name in view_model.playing_generators
- is_selected = generator_name in view_model.selected_generators
+ for channel_name in ChannelName:
+ tag = self._get_generator_checkbox_tag(channel_name)
+ is_playing = channel_name in view_model.playing_channels
+ is_selected = channel_name in view_model.selected_channels
dpg_configure_item(
tag,
enabled=is_playing,
@@ -91,14 +97,43 @@ def update_view(self, view_model: ReconstructionViewModel) -> None:
)
dpg_set_value(tag, is_selected)
if is_playing:
- ThemeRegistry.get(CHANNEL_THEME_TAGS[generator_name]).bind_to_item(tag)
+ ThemeRegistry.get(CHANNEL_THEME_TAGS[channel_name]).bind_to_item(tag)
else:
dpg.bind_item_theme(tag, 0)
+ def update_instrument_view(
+ self,
+ waveform: Optional[InstrumentWaveformViewModel],
+ ) -> None:
+ """Draws a hand-written voice's own audio, or hands the card back to the recording it shows.
+
+ An instrument writes one line and nothing to hold it against, so the controls that read a
+ recording — the autoscale switch and the per-channel boxes — stand down while one is open
+ and return with the recording that reads them.
+
+ Args:
+ waveform: The voice to draw, or ``None`` while the tab holds a recording or nothing.
+ """
+ self._show_recording_controls(shown=waveform is None)
+ if waveform is None:
+ return
+
+ self._frame_length = waveform.frame_length
+ self.waveform_display.load_voice_waveform(
+ waveform.audio,
+ name=waveform.name,
+ color=self._channel_colors.for_channel(waveform.channel_name),
+ )
+
+ def _show_recording_controls(self, *, shown: bool) -> None:
+ """Offers the switches that read a recording, which is what they have to describe."""
+ dpg_configure_item(self.autoscale_tag, show=shown)
+ dpg_configure_item(TAG_RECONSTRUCTIONS_RECONSTRUCTION_GROUP_CHANNELS, show=shown)
+
def load_waveform_data(
self,
waveform_data: WaveformData,
- generators: List[GeneratorName],
+ generators: List[ChannelName],
) -> None:
self._frame_length = waveform_data.frame_length
self.waveform_display.load_waveform_data(waveform_data, generators)
@@ -106,7 +141,7 @@ def load_waveform_data(
def update_waveform_data(
self,
waveform_data: WaveformData,
- generators: List[GeneratorName],
+ generators: List[ChannelName],
) -> None:
self.waveform_display.update_waveform_data(waveform_data, generators)
@@ -154,19 +189,18 @@ def _create_waveform_display(self) -> None:
status_bar=self._status_bar,
)
- def _create_generator_checkboxes(self) -> None:
+ def _create_channel_checkboxes(self) -> None:
generator_labels = {
- generator_name: channel_label(self._language_manager, generator_name)
- for generator_name in GeneratorName.items()
+ channel_name: channel_label(self._language_manager, channel_name) for channel_name in ChannelName.items()
}
with dpg.group(
- tag=TAG_RECONSTRUCTIONS_RECONSTRUCTION_GROUP_GENERATORS,
+ tag=TAG_RECONSTRUCTIONS_RECONSTRUCTION_GROUP_CHANNELS,
parent=self._body_container,
horizontal=True,
):
- for generator_name, label in generator_labels.items():
- tag = self._get_generator_checkbox_tag(generator_name)
+ for channel_name, label in generator_labels.items():
+ tag = self._get_generator_checkbox_tag(channel_name)
dpg.add_checkbox(
label=label,
tag=tag,
@@ -177,7 +211,7 @@ def _create_generator_checkboxes(self) -> None:
self._status_bar.bind_to_item(
tag,
- self._create_message_function_for_generator_checkbox(generator_name),
+ self._create_message_function_for_generator_checkbox(channel_name),
)
def _create_tooltips(self) -> None:
@@ -188,19 +222,19 @@ def _create_tooltips(self) -> None:
def _create_message_function_for_generator_checkbox(
self,
- generator_name: GeneratorName,
+ channel_name: ChannelName,
) -> MessageCallback:
- tag = self._get_generator_checkbox_tag(generator_name)
- name = generator_name.capitalized
+ tag = self._get_generator_checkbox_tag(channel_name)
+ name = channel_name.capitalized
def message_function(*_args: Any, **_kwargs: Any) -> str:
if not dpg.is_item_enabled(tag):
return self._language_manager[
- "reconstructions.instruments.message.status_generator_not_available"
- ].format(generator_name=name)
+ "reconstructions.instruments.message.status_channel_not_available"
+ ].format(channel_name=name)
- return self._language_manager["reconstructions.instruments.message.status_generator_toggle"].format(
- generator_name=name,
+ return self._language_manager["reconstructions.instruments.message.status_channel_toggle"].format(
+ channel_name=name,
on_or_off=(
self._language_manager["global.dialog.template.off"]
if dpg.get_value(tag)
@@ -211,25 +245,25 @@ def message_function(*_args: Any, **_kwargs: Any) -> str:
return message_function
@staticmethod
- def _get_generator_checkbox_tag(generator_name: GeneratorName) -> str:
- return compose_tag(PRE_RECONSTRUCTION_GENERATOR, generator_name.value)
+ def _get_generator_checkbox_tag(channel_name: ChannelName) -> str:
+ return compose_tag(PRE_RECONSTRUCTION_CHANNEL, channel_name.value)
- def _read_selected_generators(self) -> List[GeneratorName]:
- selected_generators: List[GeneratorName] = []
- for generator_name in GeneratorName:
- if dpg.get_value(self._get_generator_checkbox_tag(generator_name)):
- selected_generators.append(generator_name)
+ def _read_selected_generators(self) -> List[ChannelName]:
+ selected_channels: List[ChannelName] = []
+ for channel_name in ChannelName:
+ if dpg.get_value(self._get_generator_checkbox_tag(channel_name)):
+ selected_channels.append(channel_name)
- return selected_generators
+ return selected_channels
- def toggle_generator(self, generator_name: GeneratorName) -> None:
- """Switches one generator's slice in and out of the waveform and of what plays.
+ def toggle_channel(self, channel_name: ChannelName) -> None:
+ """Switches one channel's slice in and out of the waveform and of what plays.
- This is the gesture a click on the generator's checkbox makes, reached by the key the
- channel answers to. A generator the loaded reconstruction holds none of keeps the
+ This is the gesture a click on the channel's checkbox makes, reached by the key the
+ channel answers to. A channel the loaded reconstruction holds none of keeps the
checkbox its disabled state already shows.
"""
- tag = self._get_generator_checkbox_tag(generator_name)
+ tag = self._get_generator_checkbox_tag(channel_name)
if not dpg.is_item_enabled(tag):
return
@@ -237,8 +271,8 @@ def toggle_generator(self, generator_name: GeneratorName) -> None:
self._on_generator_checkbox_changed()
def _on_generator_checkbox_changed(self) -> None:
- selected_generators = self._read_selected_generators()
- self.call(self.on_generators_changed, selected_generators)
+ selected_channels = self._read_selected_generators()
+ self.call(self.on_channels_changed, selected_channels)
def _on_autoscale_changed(self, _sender: Sender, app_data: bool) -> None:
self.waveform_display.set_autoscale(app_data)
diff --git a/src/sampletones_application/ui/panels/reconstruction/stems.py b/src/sampletones_application/ui/panels/reconstruction/stems.py
new file mode 100644
index 000000000..cab5ff5e3
--- /dev/null
+++ b/src/sampletones_application/ui/panels/reconstruction/stems.py
@@ -0,0 +1,188 @@
+from typing import Callable, FrozenSet, Optional
+
+import dearpygui.dearpygui as dpg
+
+from sampletones_application.categories.manager import LanguageManager
+from sampletones_application.layout.general.stems import StemsListLayout
+from sampletones_application.tags.reconstructions import (
+ PRE_RECONSTRUCTION_STEMS,
+ TAG_RECONSTRUCTIONS_RECONSTRUCTION_CHECKBOX_COLLAPSE_LEVELS,
+ TAG_RECONSTRUCTIONS_RECONSTRUCTION_PANEL_STEMS,
+ TAG_RECONSTRUCTIONS_RECONSTRUCTION_TEXT_STEMS_EMPTY,
+ TAG_RECONSTRUCTIONS_RECONSTRUCTION_TEXT_STEMS_SETUP,
+ TAG_RECONSTRUCTIONS_RECONSTRUCTION_TOOLTIP_COLLAPSE_LEVELS,
+)
+from sampletones_application.ui.elements.fonts.font import Font
+from sampletones_application.ui.elements.fonts.registry import FontRegistry
+from sampletones_application.ui.elements.panel import GUIPanel
+from sampletones_application.ui.elements.status import GUIStatusBar
+from sampletones_application.ui.elements.stems.list import GUIStemsList
+from sampletones_application.utils.gui.dpg import dpg_configure_item, dpg_set_value
+from sampletones_application.utils.gui.tooltip import show_tooltip
+from sampletones_application.view_model.reconstruction.stems import (
+ ReconstructionStemsViewModel,
+)
+from sampletones_application.view_model.shared.stems import StemsListViewModel
+from sampletones_core.constants.enums import ChannelName, HierarchyMode
+from sampletones_shared.types.application import Sender
+from sampletones_shared.utils.system.paths import open_path_in_explorer
+
+MINIMUM_COLLAPSIBLE_LEVELS: int = 2
+
+
+class GUIReconstructionStemsPanel(GUIPanel):
+ """The recordings a loaded reconstruction was built from, as the stems list draws them.
+
+ Each row names one recording under the level it was picked on and offers a box per channel
+ it holds frames on: ticking one keeps that channel's frames in what plays, and the leading
+ box moves every channel the recording offers at once. A channel switched off for the whole
+ reconstruction shows its boxes muted while they stay as clickable as any other, so the
+ reader's per-recording choice keeps standing. A click on a row shows the recording where it
+ sits on disk, and the button beside it asks to take the recording out of the reconstruction
+ for good. The list holds on to its last row, so one recording always stands.
+ """
+
+ def __init__(
+ self,
+ *,
+ stems_layout: StemsListLayout,
+ language_manager: LanguageManager,
+ status_bar: GUIStatusBar,
+ initial_collapsed: bool = False,
+ ) -> None:
+ self._language_manager = language_manager
+ self._lbl_stems = language_manager["reconstructions.reconstruction.label.stems"]
+ self._lbl_empty = language_manager["reconstructions.reconstruction.label.stems_empty"]
+ self._lbl_collapse = language_manager["reconstructions.reconstruction.label.collapse_levels"]
+ self._setup_template = language_manager["reconstructions.reconstruction.template.stems_setup"]
+ self._mode_labels = {
+ HierarchyMode.ROUND_ROBIN: language_manager["reconstructions.reconstruction.label.stems_mode_round_robin"],
+ HierarchyMode.STRICT: language_manager["reconstructions.reconstruction.label.stems_mode_strict"],
+ }
+ self._status_bar = status_bar
+ self._view_model: Optional[ReconstructionStemsViewModel] = None
+ self._stems_list = GUIStemsList(
+ prefix=PRE_RECONSTRUCTION_STEMS,
+ layout=stems_layout,
+ language_manager=language_manager,
+ status_bar=status_bar,
+ draggable=False,
+ removable=True,
+ retain_last_row=True,
+ master_checkbox=True,
+ )
+
+ self.on_stem_channels_changed: Optional[Callable[[int, FrozenSet[ChannelName]], None]] = None
+ self.on_stem_remove_requested: Optional[Callable[[int], None]] = None
+
+ super().__init__(tag=TAG_RECONSTRUCTIONS_RECONSTRUCTION_PANEL_STEMS)
+ self._enable_vertical_collapse(
+ initial_collapsed=initial_collapsed,
+ auto_height=True,
+ )
+
+ @property
+ def stems_list(self) -> GUIStemsList:
+ """The list the recordings are drawn in, which is what addresses their widgets."""
+ return self._stems_list
+
+ def create_panel(self, parent: str) -> None:
+ with self._collapsible_card(
+ parent,
+ self._lbl_stems,
+ glyph=self._glyphs.headers.source,
+ width=0,
+ no_scrollbar=True,
+ ):
+ dpg.add_text(
+ "",
+ tag=TAG_RECONSTRUCTIONS_RECONSTRUCTION_TEXT_STEMS_SETUP,
+ parent=self._body_container,
+ show=False,
+ )
+ dpg.add_text(
+ self._lbl_empty,
+ tag=TAG_RECONSTRUCTIONS_RECONSTRUCTION_TEXT_STEMS_EMPTY,
+ parent=self._body_container,
+ show=False,
+ )
+ self._create_collapse_toggle()
+ self._stems_list.create(self._body_container, show=False)
+
+ self._stems_list.on_channels_changed = self._on_channels_changed
+ self._stems_list.on_row_activated = self._on_row_activated
+ self._stems_list.on_remove_requested = self._on_remove_requested
+
+ def update_view(self, view_model: ReconstructionStemsViewModel) -> None:
+ self._view_model = view_model
+ self._render_setup_line(view_model)
+ dpg_configure_item(
+ TAG_RECONSTRUCTIONS_RECONSTRUCTION_TEXT_STEMS_EMPTY,
+ show=view_model.show_empty_state,
+ )
+ dpg_configure_item(
+ TAG_RECONSTRUCTIONS_RECONSTRUCTION_CHECKBOX_COLLAPSE_LEVELS,
+ show=view_model.stems.level_count >= MINIMUM_COLLAPSIBLE_LEVELS,
+ )
+ dpg_configure_item(self._stems_list.tag, show=bool(view_model.stems.rows))
+ self._stems_list.update_view(self._banded(view_model.stems))
+
+ def _create_collapse_toggle(self) -> None:
+ """The reader's choice of banding: one table, or a caption per picking level."""
+ checkbox = dpg.add_checkbox(
+ label=self._lbl_collapse,
+ tag=TAG_RECONSTRUCTIONS_RECONSTRUCTION_CHECKBOX_COLLAPSE_LEVELS,
+ parent=self._body_container,
+ callback=self._on_collapse_toggled,
+ show=False,
+ )
+ FontRegistry.bind_to_item(checkbox, Font.REGULAR_SMALL)
+ show_tooltip(
+ checkbox,
+ self._language_manager["reconstructions.reconstruction.message.collapse_levels_tooltip"],
+ tag=TAG_RECONSTRUCTIONS_RECONSTRUCTION_TOOLTIP_COLLAPSE_LEVELS,
+ )
+ self._status_bar.bind_to_item(
+ TAG_RECONSTRUCTIONS_RECONSTRUCTION_CHECKBOX_COLLAPSE_LEVELS,
+ self._language_manager["reconstructions.reconstruction.message.status_collapse_levels"],
+ )
+
+ def _banded(self, stems: StemsListViewModel) -> StemsListViewModel:
+ """The list as the card draws it, under the banding the reader last asked for."""
+ collapsed = bool(dpg.get_value(TAG_RECONSTRUCTIONS_RECONSTRUCTION_CHECKBOX_COLLAPSE_LEVELS))
+ return stems.model_copy(update={"collapse_levels": collapsed})
+
+ def _render_setup_line(
+ self,
+ view_model: ReconstructionStemsViewModel,
+ ) -> None:
+ if view_model.show_setup_line:
+ mode = "" if view_model.hierarchy_mode is None else self._mode_labels[view_model.hierarchy_mode]
+ dpg_set_value(
+ TAG_RECONSTRUCTIONS_RECONSTRUCTION_TEXT_STEMS_SETUP,
+ self._setup_template.format(
+ mode=mode,
+ cap=view_model.channel_cap,
+ ),
+ )
+
+ dpg_configure_item(
+ TAG_RECONSTRUCTIONS_RECONSTRUCTION_TEXT_STEMS_SETUP,
+ show=view_model.show_setup_line,
+ )
+
+ def _on_collapse_toggled(self, _sender: Sender, _value: bool) -> None:
+ if self._view_model is not None:
+ self._stems_list.update_view(self._banded(self._view_model.stems))
+
+ def _on_channels_changed(self, key: str, channels: FrozenSet[ChannelName]) -> None:
+ self.call(self.on_stem_channels_changed, int(key), channels)
+
+ def _on_remove_requested(self, key: str) -> None:
+ self.call(self.on_stem_remove_requested, int(key))
+
+ def _on_row_activated(self, key: str) -> None:
+ """A clicked row shows its recording where it sits on disk."""
+ row = self._stems_list.row(key)
+ if row is not None and row.available:
+ open_path_in_explorer(row.path)
diff --git a/src/sampletones_application/ui/panels/sequencer/channels.py b/src/sampletones_application/ui/panels/sequencer/channels.py
index a491c76e3..41c6c52a2 100644
--- a/src/sampletones_application/ui/panels/sequencer/channels.py
+++ b/src/sampletones_application/ui/panels/sequencer/channels.py
@@ -12,11 +12,11 @@
from sampletones_application.view_model.sequencer.channels import (
SequencerChannelsViewModel,
)
-from sampletones_core.constants.enums import GeneratorName
+from sampletones_core.constants.enums import ChannelName
from sampletones_shared.types.application import Sender
from sampletones_shared.types.callback import VoidCallback
-OnChannelCallback = Callable[[GeneratorName], None]
+OnChannelCallback = Callable[[ChannelName], None]
NOTHING_MUTED: Final[SequencerChannelsViewModel] = SequencerChannelsViewModel(muted=frozenset())
@@ -72,26 +72,26 @@ def __init__(
self._on_muted = on_muted
self._on_unmuted = on_unmuted
- def click(self, sender: Sender, generator: Optional[GeneratorName]) -> None:
+ def click(self, sender: Sender, channel: Optional[ChannelName]) -> None:
"""Routes a click on a channel's name: plain mutes, ``Ctrl`` solos, the master name
switches every channel at once.
The selectable is released as the click is handled, so a name behaves as a button that
- reports the mix through its colour, and the edit cursor stays where it is.
+ reports the mix through its color, and the edit cursor stays where it is.
"""
dpg.set_value(sender, False)
- if generator is None:
+ if channel is None:
self._on_toggled()
return
if Modifier.CTRL in capture_modifiers():
- self._on_soloed(generator)
+ self._on_soloed(channel)
else:
- self._on_mute_toggled(generator)
+ self._on_mute_toggled(channel)
def add_menu_items(
self,
- generator: Optional[GeneratorName],
+ channel: Optional[ChannelName],
channels: Optional[SequencerChannelsViewModel],
) -> None:
"""Fills the open menu for one channel, or for the master name that stands for them all.
@@ -100,15 +100,15 @@ def add_menu_items(
each channel's, and either one reaches "everything" and "everything back".
"""
mix = channels if channels is not None else NOTHING_MUTED
- if generator is not None:
- self._add_channel_items(generator, mix)
+ if channel is not None:
+ self._add_channel_items(channel, mix)
dpg.add_separator()
self._add_all_channels_items(mix)
def _add_channel_items(
self,
- generator: GeneratorName,
+ channel: ChannelName,
mix: SequencerChannelsViewModel,
) -> None:
"""Offers the two gestures a click on this channel's name carries, each named for what it
@@ -118,12 +118,12 @@ def _add_channel_items(
than as a switch whose direction the user infers from the table.
"""
dpg.add_menu_item(
- label=self._labels.unmute if mix.is_muted(generator) else self._labels.mute,
- callback=lambda: self._on_mute_toggled(generator),
+ label=self._labels.unmute if mix.is_muted(channel) else self._labels.mute,
+ callback=lambda: self._on_mute_toggled(channel),
)
dpg.add_menu_item(
- label=self._labels.unsolo if mix.is_soloed(generator) else self._labels.solo,
- callback=lambda: self._on_soloed(generator),
+ label=self._labels.unsolo if mix.is_soloed(channel) else self._labels.solo,
+ callback=lambda: self._on_soloed(channel),
)
def _add_all_channels_items(self, mix: SequencerChannelsViewModel) -> None:
diff --git a/src/sampletones_application/ui/panels/sequencer/columns.py b/src/sampletones_application/ui/panels/sequencer/columns.py
index 7d2ed4ca9..feddc79db 100644
--- a/src/sampletones_application/ui/panels/sequencer/columns.py
+++ b/src/sampletones_application/ui/panels/sequencer/columns.py
@@ -1,33 +1,19 @@
from typing import Final, Optional
-from sampletones_application.layout.tabs.sequencer.colors.channel import ChannelColors
-from sampletones_application.utils.palette.colors.base import BaseColor
-from sampletones_core.constants.enums import GeneratorName
+from sampletones_core.constants.enums import ChannelName
_LEADING_TABLE_COLUMNS: Final[int] = 2
SAMPLE_TABLE_COLUMN: Final[int] = _LEADING_TABLE_COLUMNS
DIVIDER_TABLE_COLUMN: Final[int] = SAMPLE_TABLE_COLUMN + 1
_FIRST_CHANNEL_TABLE_COLUMN: Final[int] = DIVIDER_TABLE_COLUMN + 1
_TRAILING_TABLE_COLUMNS: Final[int] = 1
-TRACKER_TABLE_COLUMNS: Final[int] = _FIRST_CHANNEL_TABLE_COLUMN + len(GeneratorName.items()) + _TRAILING_TABLE_COLUMNS
+TRACKER_TABLE_COLUMNS: Final[int] = _FIRST_CHANNEL_TABLE_COLUMN + len(ChannelName.items()) + _TRAILING_TABLE_COLUMNS
HEADER_TABLE_ROW: Final[int] = 0
HEADER_TABLE_ROWS: Final[int] = HEADER_TABLE_ROW + 1
-def channel_color(colors: ChannelColors, generator: GeneratorName) -> BaseColor:
- match generator:
- case GeneratorName.PULSE1:
- return colors.pulse1
- case GeneratorName.PULSE2:
- return colors.pulse2
- case GeneratorName.TRIANGLE:
- return colors.triangle
- case GeneratorName.NOISE:
- return colors.noise
-
-
-def tracker_table_column(generator: Optional[GeneratorName]) -> int:
+def tracker_table_column(channel: Optional[ChannelName]) -> int:
"""Maps a logical column to its DPG table column index.
The visual divider between the sample column and the channels occupies a table
@@ -35,10 +21,10 @@ def tracker_table_column(generator: Optional[GeneratorName]) -> int:
logical position. The divider is purely visual, so :data:`CHANNEL_AXIS` covers
only the cursor-addressable columns.
"""
- if generator is None:
+ if channel is None:
return SAMPLE_TABLE_COLUMN
- return _FIRST_CHANNEL_TABLE_COLUMN + GeneratorName.items().index(generator)
+ return _FIRST_CHANNEL_TABLE_COLUMN + ChannelName.items().index(channel)
def tracker_table_row(row_index: int) -> int:
diff --git a/src/sampletones_application/ui/panels/sequencer/display.py b/src/sampletones_application/ui/panels/sequencer/display.py
index 4b99693ac..027a455bd 100644
--- a/src/sampletones_application/ui/panels/sequencer/display.py
+++ b/src/sampletones_application/ui/panels/sequencer/display.py
@@ -4,16 +4,18 @@
from sampletones_application.ui.panels.sequencer.input.tracker import TrackerCursor
from sampletones_application.view_model.sequencer.subcolumn import SubColumn
from sampletones_application.view_model.sequencer.tracker import SequencerCellViewModel
-from sampletones_core.constants.enums import GeneratorName
+from sampletones_application.view_model.sequencer.voices import VoiceKind
+from sampletones_core.constants.enums import ChannelName
from sampletones_core.utils.display import display_id, display_transpose, display_volume
-CellKey = Tuple[int, Optional[GeneratorName], SubColumn]
+CellKey = Tuple[int, Optional[ChannelName], SubColumn]
CellValues = Dict[CellKey, str]
+CellKinds = Dict[CellKey, Optional[VoiceKind]]
CELL_TITLE_SEPARATOR: Final[str] = " | "
_DEFAULT_LABELS: Final[Dict[SubColumn, str]] = {
- SubColumn.INSTRUMENT: display_id(None),
+ SubColumn.VOICE: display_id(None),
SubColumn.TRANSPOSE: display_transpose(None),
SubColumn.VOLUME: display_volume(None),
}
@@ -36,8 +38,8 @@ def cell_title(index: int, label: str) -> str:
def cell_display(cell_view_model: SequencerCellViewModel, subcolumn: SubColumn) -> str:
"""Extract the pre-formatted display string for one subcolumn from a cell view model."""
match subcolumn:
- case SubColumn.INSTRUMENT:
- return cell_view_model.instrument
+ case SubColumn.VOICE:
+ return cell_view_model.voice
case SubColumn.TRANSPOSE:
return cell_view_model.transpose
case SubColumn.VOLUME:
@@ -47,7 +49,7 @@ def cell_display(cell_view_model: SequencerCellViewModel, subcolumn: SubColumn)
def format_committed(subcolumn: SubColumn, value: Optional[int]) -> str:
"""Format an integer value as the display string stored in the optimistic cell cache."""
match subcolumn:
- case SubColumn.INSTRUMENT:
+ case SubColumn.VOICE:
return display_id(value)
case SubColumn.TRANSPOSE:
return display_transpose(value)
@@ -57,18 +59,16 @@ def format_committed(subcolumn: SubColumn, value: Optional[int]) -> str:
def subcolumn_label(
row: int,
- generator: Optional[GeneratorName],
+ channel: Optional[ChannelName],
subcolumn: SubColumn,
*,
cursor: Optional[TrackerCursor],
pending: str,
cell_values: CellValues,
) -> str:
- is_active = (
- cursor is not None and cursor.row == row and cursor.generator == generator and cursor.subcolumn == subcolumn
- )
+ is_active = cursor is not None and cursor.row == row and cursor.channel == channel and cursor.subcolumn == subcolumn
stored = cell_values.get(
- (row, generator, subcolumn),
+ (row, channel, subcolumn),
_DEFAULT_LABELS[subcolumn],
)
if is_active:
diff --git a/src/sampletones_application/ui/panels/sequencer/history.py b/src/sampletones_application/ui/panels/sequencer/history.py
index 9a340473c..e39dc5033 100644
--- a/src/sampletones_application/ui/panels/sequencer/history.py
+++ b/src/sampletones_application/ui/panels/sequencer/history.py
@@ -61,7 +61,7 @@ def matches(self, entry: HistoryEntryViewModel) -> bool:
class GUISequencerHistoryPanel(GUIPanel):
- """Shows the undo/redo stack: labelled entries with the current state marked.
+ """Shows the undo/redo stack: labeled entries with the current state marked.
Selecting an entry jumps the project to that state; the Undo and Redo buttons
step one entry at a time. Entries past the current one are the redo branch and
@@ -270,14 +270,14 @@ def _create_entry(
*,
before: int,
) -> None:
- """Renders one entry as a full-width selectable with coloured text on top.
+ """Renders one entry as a full-width selectable with colored text on top.
A ``span_columns`` selectable backs the whole row, so clicking anywhere
jumps to that entry and the current entry keeps the native selected
highlight. The label and each detail segment render as separate text items
- in the second column, letting every segment carry its role's colour while
+ in the second column, letting every segment carry its role's color while
the non-interactive text passes clicks through to the selectable beneath.
- A future (redo) entry greys every token.
+ A future (redo) entry grays every token.
"""
with dpg.table_row(parent=table, before=before) as row:
selectable = dpg.add_selectable(
@@ -335,16 +335,18 @@ def _role_color(self, role: HistoryDetailRole) -> BaseColor:
return roles.channel
case HistoryDetailRole.ROW:
return text.row
- case HistoryDetailRole.INSTRUMENT:
- return text.instrument
+ case HistoryDetailRole.VOICE:
+ return text.voice
case HistoryDetailRole.TRANSPOSE:
return text.transpose
case HistoryDetailRole.VOLUME:
return text.volume
case HistoryDetailRole.VALUE:
return roles.value
- case HistoryDetailRole.SAMPLE | HistoryDetailRole.NAME:
+ case HistoryDetailRole.SAMPLE:
return text.sample
+ case HistoryDetailRole.INSTRUMENT:
+ return text.instrument
case HistoryDetailRole.FEATURE_VOLUME:
return self._feature_colors.volume
case HistoryDetailRole.FEATURE_ARPEGGIO:
diff --git a/src/sampletones_application/ui/panels/sequencer/input/edit.py b/src/sampletones_application/ui/panels/sequencer/input/edit.py
index 3a8a2704e..ed8260d12 100644
--- a/src/sampletones_application/ui/panels/sequencer/input/edit.py
+++ b/src/sampletones_application/ui/panels/sequencer/input/edit.py
@@ -3,13 +3,13 @@
from pydantic.dataclasses import dataclass
from sampletones_application.view_model.sequencer.subcolumn import SubColumn
-from sampletones_core.constants.enums import GeneratorName
+from sampletones_core.constants.enums import ChannelName
@dataclass(frozen=True)
class EditAction:
row: int
- generator: Optional[GeneratorName]
+ channel: Optional[ChannelName]
sample_index: Optional[int]
transpose: Optional[int]
volume: Optional[int]
@@ -19,5 +19,5 @@ class EditAction:
@dataclass
class ClearAction:
row: int
- generator: Optional[GeneratorName]
+ channel: Optional[ChannelName]
subcolumn: Optional[SubColumn] = None
diff --git a/src/sampletones_application/ui/panels/sequencer/input/order.py b/src/sampletones_application/ui/panels/sequencer/input/order.py
index 43333b5b5..ff44a55fb 100644
--- a/src/sampletones_application/ui/panels/sequencer/input/order.py
+++ b/src/sampletones_application/ui/panels/sequencer/input/order.py
@@ -6,20 +6,21 @@
from sampletones_application.constants.sequencer import CHANNEL_AXIS
from sampletones_application.ui.panels.sequencer.input.state import GridInputState
from sampletones_application.view_model.sequencer.region import OrderRegion
-from sampletones_core.constants.enums import GeneratorName
+from sampletones_core.constants.enums import ChannelName
+from sampletones_shared.constants.general import HEXADECIMAL_BASE
INDEX_DIGITS: Final[int] = 2
@dataclass(frozen=True)
class OrderCursor:
- generator: Optional[GeneratorName]
+ channel: Optional[ChannelName]
position: int
def _parse(pending: str) -> Optional[int]:
try:
- return int(pending, 16)
+ return int(pending, HEXADECIMAL_BASE)
except ValueError:
return None
@@ -38,8 +39,8 @@ def _region_between(
first: OrderCursor,
second: OrderCursor,
) -> OrderRegion:
- first_row = CHANNEL_AXIS.index(first.generator)
- second_row = CHANNEL_AXIS.index(second.generator)
+ first_row = CHANNEL_AXIS.index(first.channel)
+ second_row = CHANNEL_AXIS.index(second.channel)
return OrderRegion(
first_row=min(first_row, second_row),
last_row=max(first_row, second_row),
@@ -48,7 +49,7 @@ def _region_between(
)
def _covers(self, region: OrderRegion, cell: OrderCursor) -> bool:
- return region.covers(cell.generator, cell.position)
+ return region.covers(cell.channel, cell.position)
def select_all(self, position_count: int) -> OrderInputState:
"""Selects the whole order: every channel row, across every position it holds."""
@@ -64,12 +65,12 @@ def select_row(
The master row is an ordinary member of the axis here, so selecting it selects a row the
way selecting a channel does.
"""
- return self._select_rows(cell.generator, cell.generator, position_count)
+ return self._select_rows(cell.channel, cell.channel, position_count)
def _select_rows(
self,
- first_generator: Optional[GeneratorName],
- last_generator: Optional[GeneratorName],
+ first_generator: Optional[ChannelName],
+ last_generator: Optional[ChannelName],
position_count: int,
) -> OrderInputState:
"""Selects a run of rows across the whole order, the cursor landing on its far corner."""
@@ -93,7 +94,7 @@ def extend_position(
new_position = value if absolute else self.cursor.position + value
new_position = max(0, min(new_position, position_count - 1))
- return self.extend_to(OrderCursor(self.cursor.generator, new_position))
+ return self.extend_to(OrderCursor(self.cursor.channel, new_position))
def extend_channel(self, value: int) -> OrderInputState:
"""Carries the selection's moving end across the channel axis, stopping at either end.
@@ -104,7 +105,7 @@ def extend_channel(self, value: int) -> OrderInputState:
if self.cursor is None:
return self
- current = CHANNEL_AXIS.index(self.cursor.generator)
+ current = CHANNEL_AXIS.index(self.cursor.channel)
row = max(0, min(current + value, len(CHANNEL_AXIS) - 1))
return self.extend_to(OrderCursor(CHANNEL_AXIS[row], self.cursor.position))
@@ -120,7 +121,7 @@ def navigate_position(
new_position = value if absolute else self.cursor.position + value
new_position = max(0, min(new_position, position_count - 1))
return OrderInputState(
- cursor=OrderCursor(self.cursor.generator, new_position),
+ cursor=OrderCursor(self.cursor.channel, new_position),
pending="",
)
@@ -128,7 +129,7 @@ def navigate_channel(self, value: int) -> OrderInputState:
if self.cursor is None:
return self
- current = CHANNEL_AXIS.index(self.cursor.generator)
+ current = CHANNEL_AXIS.index(self.cursor.channel)
new_generator = CHANNEL_AXIS[(current + value) % len(CHANNEL_AXIS)]
return OrderInputState(
cursor=OrderCursor(new_generator, self.cursor.position),
diff --git a/src/sampletones_application/ui/panels/sequencer/input/target.py b/src/sampletones_application/ui/panels/sequencer/input/target.py
index 20570b4c2..66a5cb232 100644
--- a/src/sampletones_application/ui/panels/sequencer/input/target.py
+++ b/src/sampletones_application/ui/panels/sequencer/input/target.py
@@ -32,7 +32,7 @@ def anchor(self) -> TrackerCell:
"""
return TrackerCell(
row=self.cell.row,
- generator=self.cell.generator,
+ channel=self.cell.channel,
)
@@ -52,6 +52,6 @@ def anchor(self) -> OrderCell:
"""The cell a pasted block is written from, which is the target's own channel row and
position."""
return OrderCell(
- generator=self.cell.generator,
+ channel=self.cell.channel,
position=self.cell.position,
)
diff --git a/src/sampletones_application/ui/panels/sequencer/input/tracker.py b/src/sampletones_application/ui/panels/sequencer/input/tracker.py
index 665e0c51a..9b31cf75e 100644
--- a/src/sampletones_application/ui/panels/sequencer/input/tracker.py
+++ b/src/sampletones_application/ui/panels/sequencer/input/tracker.py
@@ -18,12 +18,13 @@
slot_from_flat,
)
from sampletones_application.view_model.sequencer.subcolumn import SubColumn
-from sampletones_core.constants.enums import GeneratorName
+from sampletones_core.constants.enums import ChannelName
from sampletones_core.constants.general import MAX_VOLUME
+from sampletones_shared.constants.general import HEXADECIMAL_BASE
from sampletones_shared.constants.symbols import MINUS, PLUS, PLUS_MINUS, SIGNS
DIGIT_COUNT: Final[Dict[SubColumn, int]] = {
- SubColumn.INSTRUMENT: 2,
+ SubColumn.VOICE: 2,
SubColumn.TRANSPOSE: 2,
SubColumn.VOLUME: 1,
}
@@ -32,28 +33,28 @@
@dataclass(frozen=True)
class TrackerCursor:
row: int
- generator: Optional[GeneratorName]
+ channel: Optional[ChannelName]
subcolumn: SubColumn
def _parse(cursor: TrackerCursor, pending: str) -> Optional[EditAction]:
try:
match cursor.subcolumn:
- case SubColumn.INSTRUMENT:
+ case SubColumn.VOICE:
return EditAction(
row=cursor.row,
- generator=cursor.generator,
- sample_index=int(pending, 16),
+ channel=cursor.channel,
+ sample_index=int(pending, HEXADECIMAL_BASE),
transpose=None,
volume=None,
)
case SubColumn.VOLUME:
return EditAction(
row=cursor.row,
- generator=cursor.generator,
+ channel=cursor.channel,
sample_index=None,
transpose=None,
- volume=min(int(pending, 16), MAX_VOLUME),
+ volume=min(int(pending, HEXADECIMAL_BASE), MAX_VOLUME),
)
case SubColumn.TRANSPOSE:
sign = -1 if pending.startswith(MINUS) else 1
@@ -63,9 +64,9 @@ def _parse(cursor: TrackerCursor, pending: str) -> Optional[EditAction]:
return EditAction(
row=cursor.row,
- generator=cursor.generator,
+ channel=cursor.channel,
sample_index=None,
- transpose=sign * int(magnitude, 16),
+ transpose=sign * int(magnitude, HEXADECIMAL_BASE),
volume=None,
)
except ValueError:
@@ -86,8 +87,8 @@ def _region_between(
first: TrackerCursor,
second: TrackerCursor,
) -> TrackerRegion:
- first_slot = TrackerSlot(first.generator, first.subcolumn).flat_index
- second_slot = TrackerSlot(second.generator, second.subcolumn).flat_index
+ first_slot = TrackerSlot(first.channel, first.subcolumn).flat_index
+ second_slot = TrackerSlot(second.channel, second.subcolumn).flat_index
return TrackerRegion(
first_row=min(first.row, second.row),
last_row=max(first.row, second.row),
@@ -96,7 +97,7 @@ def _region_between(
)
def _covers(self, region: TrackerRegion, cell: TrackerCursor) -> bool:
- return region.covers(cell.row, TrackerSlot(cell.generator, cell.subcolumn))
+ return region.covers(cell.row, TrackerSlot(cell.channel, cell.subcolumn))
def select_all(self, row_count: int) -> TrackerInputState:
"""Selects the whole frame: every row of it, across every slot the axis lays out."""
@@ -112,7 +113,7 @@ def select_column(
The sample column is an ordinary member of the axis here, so selecting it selects a column
the way selecting a channel does.
"""
- base = column_slot_base(cell.generator)
+ base = column_slot_base(cell.channel)
return self._select_slots(base, base + len(SUBCOLUMNS) - 1, row_count)
def select_subcolumn(
@@ -121,7 +122,7 @@ def select_subcolumn(
row_count: int,
) -> TrackerInputState:
"""Selects the subcolumn ``cell`` stands in: every row of it, at that one slot."""
- slot = TrackerSlot(cell.generator, cell.subcolumn).flat_index
+ slot = TrackerSlot(cell.channel, cell.subcolumn).flat_index
return self._select_slots(slot, slot, row_count)
def _select_slots(
@@ -137,8 +138,8 @@ def _select_slots(
first = slot_from_flat(first_slot)
last = slot_from_flat(last_slot)
return self.select_between(
- TrackerCursor(0, first.generator, first.subcolumn),
- TrackerCursor(row_count - 1, last.generator, last.subcolumn),
+ TrackerCursor(0, first.channel, first.subcolumn),
+ TrackerCursor(row_count - 1, last.channel, last.subcolumn),
)
def extend_row(
@@ -156,7 +157,7 @@ def extend_row(
return self.extend_to(
TrackerCursor(
new_row,
- self.cursor.generator,
+ self.cursor.channel,
self.cursor.subcolumn,
)
)
@@ -171,14 +172,14 @@ def extend_slot(self, value: int) -> TrackerInputState:
return self
current = TrackerSlot(
- self.cursor.generator,
+ self.cursor.channel,
self.cursor.subcolumn,
).flat_index
slot = slot_from_flat(max(0, min(current + value, SLOT_COUNT - 1)))
return self.extend_to(
TrackerCursor(
self.cursor.row,
- slot.generator,
+ slot.channel,
slot.subcolumn,
)
)
@@ -197,7 +198,7 @@ def navigate_row(
return TrackerInputState(
cursor=TrackerCursor(
new_row,
- self.cursor.generator,
+ self.cursor.channel,
self.cursor.subcolumn,
),
pending="",
@@ -211,7 +212,7 @@ def navigate_subcolumn(
"""Steps the cursor along the flattened slot axis, wrapping at either end.
Wrapping is a navigation policy the cursor owns: walking right off the last
- volume slot lands on the sample column's instrument, so a held arrow key
+ volume slot lands on the sample column's voice slot, so a held arrow key
tours the whole row.
"""
if self.cursor is None:
@@ -222,21 +223,21 @@ def navigate_subcolumn(
return TrackerInputState(
cursor=TrackerCursor(
self.cursor.row,
- self.cursor.generator,
+ self.cursor.channel,
new_sub,
),
pending="",
)
current = TrackerSlot(
- self.cursor.generator,
+ self.cursor.channel,
self.cursor.subcolumn,
).flat_index
slot = slot_from_flat((current + value) % SLOT_COUNT)
return TrackerInputState(
cursor=TrackerCursor(
self.cursor.row,
- slot.generator,
+ slot.channel,
slot.subcolumn,
),
pending="",
@@ -246,7 +247,7 @@ def navigate_column_by(self, delta: int) -> TrackerInputState:
if self.cursor is None:
return self
- current_idx = CHANNEL_AXIS.index(self.cursor.generator)
+ current_idx = CHANNEL_AXIS.index(self.cursor.channel)
next_idx = (current_idx + delta) % len(CHANNEL_AXIS)
return TrackerInputState(
cursor=TrackerCursor(
@@ -264,7 +265,7 @@ def type_char(
if self.cursor is None:
return self, None
- if self.cursor.subcolumn is SubColumn.INSTRUMENT and char == MINUS:
+ if self.cursor.subcolumn is SubColumn.VOICE and char == MINUS:
return self._after_entry(), self._note_off_action(self.cursor)
if self.cursor.subcolumn is SubColumn.TRANSPOSE:
@@ -284,7 +285,7 @@ def type_char(
def _note_off_action(self, cursor: TrackerCursor) -> EditAction:
return EditAction(
row=cursor.row,
- generator=cursor.generator,
+ channel=cursor.channel,
sample_index=None,
transpose=None,
volume=None,
@@ -340,14 +341,14 @@ def commit_partial(self) -> Tuple[TrackerInputState, Optional[EditAction]]:
def clear(self) -> Tuple[TrackerInputState, ClearAction]:
action = ClearAction(
row=self.cursor.row if self.cursor else 0,
- generator=self.cursor.generator if self.cursor else None,
+ channel=self.cursor.channel if self.cursor else None,
)
return self.reset_pending(), action
def clear_subcolumn(self) -> Tuple[TrackerInputState, ClearAction]:
action = ClearAction(
row=self.cursor.row if self.cursor else 0,
- generator=self.cursor.generator if self.cursor else None,
+ channel=self.cursor.channel if self.cursor else None,
subcolumn=self.cursor.subcolumn if self.cursor else None,
)
return self.reset_pending(), action
diff --git a/tests/unit/sampletones_core/trackers/__init__.py b/src/sampletones_application/ui/panels/sequencer/order/__init__.py
similarity index 100%
rename from tests/unit/sampletones_core/trackers/__init__.py
rename to src/sampletones_application/ui/panels/sequencer/order/__init__.py
diff --git a/src/sampletones_application/ui/panels/sequencer/order/callbacks.py b/src/sampletones_application/ui/panels/sequencer/order/callbacks.py
new file mode 100644
index 000000000..c8f2776d9
--- /dev/null
+++ b/src/sampletones_application/ui/panels/sequencer/order/callbacks.py
@@ -0,0 +1,23 @@
+from typing import Callable, Optional, Tuple
+
+from sampletones_application.ui.panels.sequencer.grid.surface.edit import GridEditSurface
+from sampletones_application.ui.panels.sequencer.input.order import OrderCursor
+from sampletones_application.ui.panels.sequencer.input.target import OrderTarget
+from sampletones_application.view_model.sequencer.region import OrderCell, OrderRegion
+from sampletones_core.constants.enums import ChannelName
+
+OrderKey = Tuple[Optional[ChannelName], int]
+
+OnFrameSelectedCallback = Callable[[int], None]
+OnRemoveCallback = Callable[[int], None]
+OnFrameActionCallback = Callable[[int], None]
+OnMoveCallback = Callable[[int, int], None]
+OnSetOrderEntryCallback = Callable[[ChannelName, int, Optional[int]], None]
+OnSetMasterEntryCallback = Callable[[int, Optional[int]], None]
+OnChannelMuteToggledCallback = Callable[[ChannelName], None]
+OnChannelSoloedCallback = Callable[[ChannelName], None]
+OnBlockRegionCallback = Callable[[OrderRegion], None]
+OnPasteBlockCallback = Callable[[OrderCell], None]
+CanPasteBlockQuery = Callable[[], bool]
+
+OrderEditSurface = GridEditSurface[OrderCursor, OrderRegion, OrderCell, OrderTarget]
diff --git a/src/sampletones_application/ui/panels/sequencer/order/menu.py b/src/sampletones_application/ui/panels/sequencer/order/menu.py
new file mode 100644
index 000000000..a2289310d
--- /dev/null
+++ b/src/sampletones_application/ui/panels/sequencer/order/menu.py
@@ -0,0 +1,222 @@
+from typing import Optional, Protocol
+
+import dearpygui.dearpygui as dpg
+
+from sampletones_application.categories.elements.sequencer import SequencerOrderElements
+from sampletones_application.categories.hierarchy import Page, Panel, TextType
+from sampletones_application.categories.manager import LanguageManager
+from sampletones_application.ui.elements.context_menu import add_play_menu_item, context_menu
+from sampletones_application.ui.elements.fonts.font import Font
+from sampletones_application.ui.elements.fonts.registry import FontRegistry
+from sampletones_application.ui.panels.sequencer.channels import ChannelSwitch
+from sampletones_application.ui.panels.sequencer.display import cell_title
+from sampletones_application.ui.panels.sequencer.input.order import OrderCursor
+from sampletones_application.ui.panels.sequencer.input.target import OrderTarget
+from sampletones_application.ui.panels.sequencer.order.callbacks import (
+ OnFrameActionCallback,
+ OnMoveCallback,
+ OnRemoveCallback,
+ OrderEditSurface,
+)
+from sampletones_application.ui.panels.sequencer.order.moves import MOVE_DIRECTIONS
+from sampletones_application.utils.gui.shortcuts.ids import ShortcutId
+from sampletones_application.utils.gui.shortcuts.source import ShortcutSource
+from sampletones_application.view_model.sequencer.channels import SequencerChannelsViewModel
+from sampletones_core.constants.enums import ChannelName
+from sampletones_shared.utils.callbacks import CallbackMixin
+
+
+class OrderMenuHost(Protocol):
+ """What the order panel states to the menus raised over its table.
+
+ The hooks are the panel's own, so the coordinator keeps wiring them where it already does, and
+ a menu reads whichever answer stands at the moment it opens.
+ """
+
+ on_play_from_requested: Optional[OnFrameActionCallback]
+ on_duplicate_requested: Optional[OnFrameActionCallback]
+ on_clone_requested: Optional[OnFrameActionCallback]
+ on_insert_requested: Optional[OnFrameActionCallback]
+ on_clear_requested: Optional[OnFrameActionCallback]
+ on_remove_requested: Optional[OnRemoveCallback]
+ on_move_requested: Optional[OnMoveCallback]
+
+ @property
+ def edit_surface(self) -> OrderEditSurface: ...
+
+ @property
+ def channel_switch(self) -> ChannelSwitch: ...
+
+ @property
+ def channels(self) -> Optional[SequencerChannelsViewModel]: ...
+
+ @property
+ def position_count(self) -> int: ...
+
+ def row_label(self, channel: Optional[ChannelName]) -> str: ...
+
+ def select_shape(self, shortcut_id: ShortcutId, cell: OrderCursor) -> bool: ...
+
+
+class OrderMenu(CallbackMixin):
+ """Every menu the order table offers, wherever a reader raised it.
+
+ A cell menu names the frame a pointer landed on and a row-label menu the channel beneath it,
+ while the menu bar's **Edit** group asks for the cell the cursor stands on. The table states
+ its actions once in :meth:`add_action_items`, so an action added there reaches every door.
+
+ What a menu offers about a frame is asked for as it opens — how far it can move, which channels
+ stand silenced — which keeps what a menu prints and what a click does one answer.
+ """
+
+ def __init__(
+ self,
+ panel: OrderMenuHost,
+ *,
+ language_manager: LanguageManager,
+ shortcut_source: ShortcutSource,
+ ) -> None:
+ self._panel = panel
+ self._shortcuts = shortcut_source
+ self._lbl_play = self._label(language_manager, SequencerOrderElements.CONTEXT_PLAY)
+ self._lbl_select_all = self._label(language_manager, SequencerOrderElements.CONTEXT_SELECT_ALL)
+ self._lbl_select_row = self._label(language_manager, SequencerOrderElements.CONTEXT_SELECT_ROW)
+ self._lbl_duplicate = self._label(language_manager, SequencerOrderElements.CONTEXT_DUPLICATE)
+ self._lbl_clone = self._label(language_manager, SequencerOrderElements.CONTEXT_CLONE)
+ self._lbl_insert = self._label(language_manager, SequencerOrderElements.CONTEXT_INSERT)
+ self._lbl_clear = self._label(language_manager, SequencerOrderElements.CONTEXT_CLEAR)
+ self._lbl_remove = self._label(language_manager, SequencerOrderElements.CONTEXT_REMOVE)
+ self._lbl_move_left = self._label(language_manager, SequencerOrderElements.CONTEXT_MOVE_LEFT)
+ self._lbl_move_right = self._label(language_manager, SequencerOrderElements.CONTEXT_MOVE_RIGHT)
+ self._lbl_move_start = self._label(language_manager, SequencerOrderElements.CONTEXT_MOVE_START)
+ self._lbl_move_end = self._label(language_manager, SequencerOrderElements.CONTEXT_MOVE_END)
+
+ @staticmethod
+ def _label(
+ language_manager: LanguageManager,
+ element: SequencerOrderElements,
+ ) -> str:
+ return language_manager[
+ Page.SEQUENCER,
+ Panel.ORDER,
+ TextType.LABEL,
+ element,
+ ]
+
+ def show_for_channel(self, channel: Optional[ChannelName]) -> None:
+ """Opens the menu behind a row label, titled with the row's own name."""
+ with context_menu():
+ header = dpg.add_text(self._panel.row_label(channel))
+ FontRegistry.bind_to_item(header, Font.MONO_BOLD)
+ dpg.add_separator()
+ self._panel.channel_switch.add_menu_items(channel, self._panel.channels)
+
+ def show_for_cell(
+ self,
+ channel: Optional[ChannelName],
+ position: int,
+ ) -> None:
+ """Opens the frame-operations menu, titled with the cell the pointer landed on."""
+ target = self._panel.edit_surface.target_at(OrderCursor(channel, position))
+ with context_menu():
+ header = dpg.add_text(cell_title(position, self._panel.row_label(channel)))
+ FontRegistry.bind_to_item(header, Font.MONO_BOLD)
+ dpg.add_separator()
+ add_play_menu_item(
+ self._lbl_play,
+ lambda: self.call(self._panel.on_play_from_requested, position),
+ shortcut=self._shortcuts.display(ShortcutId.PLAY_FROM_FRAME),
+ )
+ dpg.add_separator()
+ self.add_action_items(target)
+
+ def add_action_items(self, target: OrderTarget) -> None:
+ """Builds every action an order cell offers, in the order each menu prints them.
+
+ The table states its actions once, and whoever asks for them decides where they are shown:
+ the cell menu asks for the cell a pointer landed on, and the menu bar asks for the cell the
+ cursor stands on. An action added here reaches both.
+ """
+ self._add_select_items(target.cell)
+ dpg.add_separator()
+ self._panel.edit_surface.add_block_items(target)
+ dpg.add_separator()
+ self._add_frame_items(target.cell.position)
+ dpg.add_separator()
+ self._add_move_items(target.cell.position)
+
+ def _add_select_items(self, cell: OrderCursor) -> None:
+ """Builds the two shapes a selection takes, the whole order and one row of it.
+
+ Each item fires the gesture its key fires, on the cell the menu names: a row selected from
+ a cell menu is the row that cell stands in, and one selected from the menu bar is the row
+ the cursor stands in.
+ """
+ dpg.add_menu_item(
+ label=self._lbl_select_all,
+ shortcut=self._shortcuts.display(ShortcutId.ORDER_SELECT_ALL),
+ callback=lambda: self._panel.select_shape(ShortcutId.ORDER_SELECT_ALL, cell),
+ )
+ dpg.add_menu_item(
+ label=self._lbl_select_row,
+ shortcut=self._shortcuts.display(ShortcutId.ORDER_SELECT_ROW),
+ callback=lambda: self._panel.select_shape(ShortcutId.ORDER_SELECT_ROW, cell),
+ )
+
+ def _add_frame_items(self, position: int) -> None:
+ """Builds the frame operations, each acting on the whole frame the target cell sits in.
+
+ Each item reads its hook as it fires rather than as it is built, so an item answers
+ whatever the coordinator has wired by the time a reader clicks it.
+ """
+ dpg.add_menu_item(
+ label=self._lbl_duplicate,
+ shortcut=self._shortcuts.display(ShortcutId.ORDER_DUPLICATE_FRAME),
+ callback=lambda: self.call(self._panel.on_duplicate_requested, position),
+ )
+ dpg.add_menu_item(
+ label=self._lbl_clone,
+ shortcut=self._shortcuts.display(ShortcutId.ORDER_CLONE_FRAME),
+ callback=lambda: self.call(self._panel.on_clone_requested, position),
+ )
+ dpg.add_menu_item(
+ label=self._lbl_insert,
+ shortcut=self._shortcuts.display(ShortcutId.ORDER_INSERT_FRAME),
+ callback=lambda: self.call(self._panel.on_insert_requested, position),
+ )
+ dpg.add_menu_item(
+ label=self._lbl_clear,
+ shortcut=self._shortcuts.display(ShortcutId.ORDER_CLEAR_FRAME),
+ callback=lambda: self.call(self._panel.on_clear_requested, position),
+ )
+ dpg.add_menu_item(
+ label=self._lbl_remove,
+ shortcut=self._shortcuts.display(ShortcutId.ORDER_REMOVE_FRAME),
+ callback=lambda: self.call(self._panel.on_remove_requested, position),
+ )
+
+ def _add_move_items(self, position: int) -> None:
+ """Builds the four moves a frame can make, in the order they walk the song."""
+ self._add_move_item(self._lbl_move_left, ShortcutId.ORDER_MOVE_FRAME_LEFT, position)
+ self._add_move_item(self._lbl_move_right, ShortcutId.ORDER_MOVE_FRAME_RIGHT, position)
+ self._add_move_item(self._lbl_move_start, ShortcutId.ORDER_MOVE_FRAME_TO_START, position)
+ self._add_move_item(self._lbl_move_end, ShortcutId.ORDER_MOVE_FRAME_TO_END, position)
+
+ def _add_move_item(
+ self,
+ label: str,
+ shortcut_id: ShortcutId,
+ position: int,
+ ) -> None:
+ """Adds a move item, grayed out (disabled) when the move would have no effect.
+
+ The action names both the direction it moves and the accelerator it prints, so the item a
+ reader sees is the one the key press performs.
+ """
+ target = MOVE_DIRECTIONS[shortcut_id].target(position, self._panel.position_count)
+ dpg.add_menu_item(
+ label=label,
+ shortcut=self._shortcuts.display(shortcut_id),
+ enabled=target is not None,
+ callback=lambda: self.call(self._panel.on_move_requested, position, target),
+ )
diff --git a/src/sampletones_application/ui/panels/sequencer/order/moves.py b/src/sampletones_application/ui/panels/sequencer/order/moves.py
new file mode 100644
index 000000000..96bea9810
--- /dev/null
+++ b/src/sampletones_application/ui/panels/sequencer/order/moves.py
@@ -0,0 +1,11 @@
+from typing import Dict, Final
+
+from sampletones_application.utils.gui.shortcuts.ids import ShortcutId
+from sampletones_application.view_model.sequencer.move import MoveDirection
+
+MOVE_DIRECTIONS: Final[Dict[ShortcutId, MoveDirection]] = {
+ ShortcutId.ORDER_MOVE_FRAME_LEFT: MoveDirection.PREVIOUS,
+ ShortcutId.ORDER_MOVE_FRAME_RIGHT: MoveDirection.NEXT,
+ ShortcutId.ORDER_MOVE_FRAME_TO_START: MoveDirection.FIRST,
+ ShortcutId.ORDER_MOVE_FRAME_TO_END: MoveDirection.LAST,
+}
diff --git a/src/sampletones_application/ui/panels/sequencer/order.py b/src/sampletones_application/ui/panels/sequencer/order/panel.py
similarity index 79%
rename from src/sampletones_application/ui/panels/sequencer/order.py
rename to src/sampletones_application/ui/panels/sequencer/order/panel.py
index 0be02c15a..8dda52a4d 100644
--- a/src/sampletones_application/ui/panels/sequencer/order.py
+++ b/src/sampletones_application/ui/panels/sequencer/order/panel.py
@@ -1,4 +1,4 @@
-from typing import Callable, Dict, Final, FrozenSet, Optional, Set, Tuple
+from typing import Dict, Final, FrozenSet, Optional, Set, Tuple
import dearpygui.dearpygui as dpg
@@ -6,6 +6,7 @@
from sampletones_application.categories.hierarchy import Page, Panel, TextType
from sampletones_application.categories.manager import LanguageManager
from sampletones_application.constants.sequencer import CHANNEL_AXIS
+from sampletones_application.layout.general.colors.channel import ChannelColors
from sampletones_application.layout.general.plus_minus_buttons import (
PlusMinusButtonsLayout,
)
@@ -24,10 +25,6 @@
TAG_SEQUENCER_ORDER_WINDOW_ORDER_CARD,
TAG_SEQUENCER_THEME_TABLE_ORDER,
)
-from sampletones_application.ui.elements.context_menu import (
- add_play_menu_item,
- context_menu,
-)
from sampletones_application.ui.elements.fonts.font import Font
from sampletones_application.ui.elements.fonts.registry import FontRegistry
from sampletones_application.ui.elements.panel import GUIPanel
@@ -43,8 +40,6 @@
ChannelSwitch,
channel_tooltip,
)
-from sampletones_application.ui.panels.sequencer.columns import channel_color
-from sampletones_application.ui.panels.sequencer.display import cell_title
from sampletones_application.ui.panels.sequencer.grid.gestures import BlockGestures
from sampletones_application.ui.panels.sequencer.grid.scroll.axis import (
HorizontalScroll,
@@ -64,6 +59,23 @@
OrderInputState,
)
from sampletones_application.ui.panels.sequencer.input.target import OrderTarget
+from sampletones_application.ui.panels.sequencer.order.callbacks import (
+ CanPasteBlockQuery,
+ OnBlockRegionCallback,
+ OnChannelMuteToggledCallback,
+ OnChannelSoloedCallback,
+ OnFrameActionCallback,
+ OnFrameSelectedCallback,
+ OnMoveCallback,
+ OnPasteBlockCallback,
+ OnRemoveCallback,
+ OnSetMasterEntryCallback,
+ OnSetOrderEntryCallback,
+ OrderEditSurface,
+ OrderKey,
+)
+from sampletones_application.ui.panels.sequencer.order.menu import OrderMenu
+from sampletones_application.ui.panels.sequencer.order.moves import MOVE_DIRECTIONS
from sampletones_application.ui.themes.inline import (
create_header_selectable_theme,
create_selectable_text_theme,
@@ -88,38 +100,15 @@
from sampletones_application.view_model.sequencer.channels import (
SequencerChannelsViewModel,
)
-from sampletones_application.view_model.sequencer.move import MoveDirection
from sampletones_application.view_model.sequencer.order import (
SequencerOrderTrackerViewModel,
)
from sampletones_application.view_model.sequencer.region import OrderCell, OrderRegion
-from sampletones_core.constants.enums import GeneratorName
+from sampletones_core.constants.enums import ChannelName
from sampletones_core.utils.display import display_id
from sampletones_shared.types.application import ColorRGBA, Sender
from sampletones_shared.types.callback import VoidCallback
-OrderKey = Tuple[Optional[GeneratorName], int]
-
-OnFrameSelectedCallback = Callable[[int], None]
-OnRemoveCallback = Callable[[int], None]
-OnFrameActionCallback = Callable[[int], None]
-OnMoveCallback = Callable[[int, int], None]
-OnSetOrderEntryCallback = Callable[[GeneratorName, int, Optional[int]], None]
-OnSetMasterEntryCallback = Callable[[int, Optional[int]], None]
-OnChannelMuteToggledCallback = Callable[[GeneratorName], None]
-OnChannelSoloedCallback = Callable[[GeneratorName], None]
-OnBlockRegionCallback = Callable[[OrderRegion], None]
-OnPasteBlockCallback = Callable[[OrderCell], None]
-CanPasteBlockQuery = Callable[[], bool]
-OrderEditSurface = GridEditSurface[OrderCursor, OrderRegion, OrderCell, OrderTarget]
-
-MOVE_DIRECTIONS: Final[Dict[ShortcutId, MoveDirection]] = {
- ShortcutId.ORDER_MOVE_FRAME_LEFT: MoveDirection.PREVIOUS,
- ShortcutId.ORDER_MOVE_FRAME_RIGHT: MoveDirection.NEXT,
- ShortcutId.ORDER_MOVE_FRAME_TO_START: MoveDirection.FIRST,
- ShortcutId.ORDER_MOVE_FRAME_TO_END: MoveDirection.LAST,
-}
-
MASTER_TABLE_ROW: Final[int] = 0
DIVIDER_TABLE_ROW: Final[int] = 1
@@ -141,6 +130,7 @@ def __init__(
self,
*,
layout: SequencerLayout,
+ channel_colors: ChannelColors,
plus_minus_layout: PlusMinusButtonsLayout,
language_manager: LanguageManager,
key_router: KeyRouter,
@@ -149,6 +139,7 @@ def __init__(
initial_collapsed: bool = False,
) -> None:
self._layout = layout
+ self._channel_colors = channel_colors
self._plus_minus_layout = plus_minus_layout
self._router = key_router
self._tab_active = tab_active
@@ -174,7 +165,7 @@ def __init__(
self._cell_handler_tag = compose_tag(TAG_SEQUENCER_ORDER_TABLE, SUF_HANDLER_REGISTRY)
self._label_handler_tag = compose_tag(TAG_SEQUENCER_ORDER_TABLE, SUF_HANDLER_HEADER)
self._drag_handler_tag = compose_tag(TAG_SEQUENCER_ORDER_TABLE, SUF_HANDLER_DRAG)
- self._label_rows: Dict[Sender, Optional[GeneratorName]] = {}
+ self._label_rows: Dict[Sender, Optional[ChannelName]] = {}
self._entry_theme: int = 0
self._muted_entry_theme: int = 0
self._label_theme: int = 0
@@ -219,9 +210,13 @@ def __init__(
)
self._lbl_order = self._label(language_manager, SequencerOrderElements.ORDER_TEXT)
self._load_row_labels(language_manager)
- self._load_context_labels(language_manager)
self._load_label_tooltips(language_manager)
self._create_channel_switch(language_manager)
+ self._menu = OrderMenu(
+ self,
+ language_manager=language_manager,
+ shortcut_source=shortcut_source,
+ )
super().__init__(
tag=TAG_SEQUENCER_ORDER_PANEL,
@@ -238,31 +233,14 @@ def _label(language_manager: LanguageManager, element: SequencerOrderElements) -
def _load_row_labels(self, language_manager: LanguageManager) -> None:
"""Reads the name each row carries, which its label and its menu title show."""
- self._row_labels: Dict[Optional[GeneratorName], str] = {
+ self._row_labels: Dict[Optional[ChannelName], str] = {
None: self._label(language_manager, SequencerOrderElements.ROW_MASTER),
- GeneratorName.PULSE1: self._label(language_manager, SequencerOrderElements.ROW_PULSE_1),
- GeneratorName.PULSE2: self._label(language_manager, SequencerOrderElements.ROW_PULSE_2),
- GeneratorName.TRIANGLE: self._label(language_manager, SequencerOrderElements.ROW_TRIANGLE),
- GeneratorName.NOISE: self._label(language_manager, SequencerOrderElements.ROW_NOISE),
+ ChannelName.PULSE1: self._label(language_manager, SequencerOrderElements.ROW_PULSE_1),
+ ChannelName.PULSE2: self._label(language_manager, SequencerOrderElements.ROW_PULSE_2),
+ ChannelName.TRIANGLE: self._label(language_manager, SequencerOrderElements.ROW_TRIANGLE),
+ ChannelName.NOISE: self._label(language_manager, SequencerOrderElements.ROW_NOISE),
}
- def _load_context_labels(self, language_manager: LanguageManager) -> None:
- def label(element: SequencerOrderElements) -> str:
- return self._label(language_manager, element)
-
- self._lbl_context_play = label(SequencerOrderElements.CONTEXT_PLAY)
- self._lbl_context_select_all = label(SequencerOrderElements.CONTEXT_SELECT_ALL)
- self._lbl_context_select_row = label(SequencerOrderElements.CONTEXT_SELECT_ROW)
- self._lbl_context_duplicate = label(SequencerOrderElements.CONTEXT_DUPLICATE)
- self._lbl_context_clone = label(SequencerOrderElements.CONTEXT_CLONE)
- self._lbl_context_insert = label(SequencerOrderElements.CONTEXT_INSERT)
- self._lbl_context_clear = label(SequencerOrderElements.CONTEXT_CLEAR)
- self._lbl_context_remove = label(SequencerOrderElements.CONTEXT_REMOVE)
- self._lbl_context_move_left = label(SequencerOrderElements.CONTEXT_MOVE_LEFT)
- self._lbl_context_move_right = label(SequencerOrderElements.CONTEXT_MOVE_RIGHT)
- self._lbl_context_move_start = label(SequencerOrderElements.CONTEXT_MOVE_START)
- self._lbl_context_move_end = label(SequencerOrderElements.CONTEXT_MOVE_END)
-
def _load_label_tooltips(self, language_manager: LanguageManager) -> None:
"""Reads the row-label tooltips, which name the click gestures the labels carry."""
@@ -288,8 +266,8 @@ def _create_channel_switch(self, language_manager: LanguageManager) -> None:
)
self._channel_switch = ChannelSwitch(
labels=labels,
- on_mute_toggled=lambda generator: self.call(self.on_channel_mute_toggled, generator),
- on_soloed=lambda generator: self.call(self.on_channel_soloed, generator),
+ on_mute_toggled=lambda channel: self.call(self.on_channel_mute_toggled, channel),
+ on_soloed=lambda channel: self.call(self.on_channel_soloed, channel),
on_toggled=lambda: self.call(self.on_channels_toggled),
on_muted=lambda: self.call(self.on_channels_muted),
on_unmuted=lambda: self.call(self.on_channels_unmuted),
@@ -310,10 +288,10 @@ def create_panel(self, parent: str) -> None:
self._register_handlers()
def _create_entry_themes(self) -> None:
- """Colours every pattern entry, in the shade its channel sounds and the shade it is silenced.
+ """Colors every pattern entry, in the shade its channel sounds and the shade it is silenced.
- The entry themes target only the selectable text, so they leave every other colour to the
- global theme; the dimmed variant keeps the entry colour at reduced alpha, so a silenced
+ The entry themes target only the selectable text, so they leave every other color to the
+ global theme; the dimmed variant keeps the entry color at reduced alpha, so a silenced
channel's frames stay readable and editable while the others are worked on. A row label
carries the header's hover and press washes instead, so it reads as the switch it is.
"""
@@ -405,7 +383,7 @@ def _follow_frame(self, frame: int) -> None:
return
new_state = OrderInputState(
- cursor=OrderCursor(cursor.generator, frame),
+ cursor=OrderCursor(cursor.channel, frame),
)
if 0 <= frame < self._position_count:
self._apply_state(new_state, notify=False)
@@ -463,26 +441,26 @@ def _apply_channel_cues(self) -> None:
self._tint_channel_rows()
self._bind_label_themes()
- for generator in GeneratorName.items():
- self._bind_channel_entry_themes(generator)
+ for channel in ChannelName.items():
+ self._bind_channel_entry_themes(channel)
def _bind_label_themes(self) -> None:
- for label, generator in self._label_rows.items():
- muted = generator is not None and self._is_muted(generator)
+ for label, channel in self._label_rows.items():
+ muted = channel is not None and self._is_muted(channel)
dpg.bind_item_theme(
label,
self._muted_label_theme if muted else self._label_theme,
)
- def _bind_channel_entry_themes(self, generator: GeneratorName) -> None:
- theme = self._muted_entry_theme if self._is_muted(generator) else self._entry_theme
+ def _bind_channel_entry_themes(self, channel: ChannelName) -> None:
+ theme = self._muted_entry_theme if self._is_muted(channel) else self._entry_theme
for position in range(self._position_count):
- widget = self._order.widget((generator, position))
+ widget = self._order.widget((channel, position))
if widget is not None:
dpg.bind_item_theme(widget, theme)
- def _is_muted(self, generator: GeneratorName) -> bool:
- return self._current_channels is not None and self._current_channels.is_muted(generator)
+ def _is_muted(self, channel: ChannelName) -> bool:
+ return self._current_channels is not None and self._current_channels.is_muted(channel)
def _compute_cell_values(
self,
@@ -491,9 +469,9 @@ def _compute_cell_values(
cell_values: Dict[OrderKey, str] = {}
for position in range(view_model.position_count):
cell_values[(None, position)] = view_model.master_label(position)
- for generator in GeneratorName.items():
- cell_values[(generator, position)] = view_model.entry_label(
- generator,
+ for channel in ChannelName.items():
+ cell_values[(channel, position)] = view_model.entry_label(
+ channel,
position,
)
@@ -546,7 +524,7 @@ def _build_table(self, position_count: int) -> None:
label="",
parent=TAG_SEQUENCER_ORDER_TABLE,
width_fixed=True,
- init_width_or_weight=self._layout.table_cells.generator,
+ init_width_or_weight=self._layout.table_cells.channel,
)
for position in range(position_count):
dpg.add_table_column(
@@ -557,9 +535,9 @@ def _build_table(self, position_count: int) -> None:
)
self._label_rows = {}
- for generator in CHANNEL_AXIS:
- self._build_row(generator, position_count)
- if generator is None:
+ for channel in CHANNEL_AXIS:
+ self._build_row(channel, position_count)
+ if channel is None:
self._build_divider_row(position_count)
self._apply_column_backgrounds()
@@ -570,8 +548,8 @@ def repaint(self) -> None:
"""Issues every tint the table holds as its own state.
DearPyGui keeps a row, column or cell highlight on the table rather than on an item,
- so a colour reaches it only by being pushed again. Gathering the pushes here gives
- the palette one call to make and keeps a rebuilt table and a recoloured one identical.
+ so a color reaches it only by being pushed again. Gathering the pushes here gives
+ the palette one call to make and keeps a rebuilt table and a recolored one identical.
"""
if not dpg.does_item_exist(TAG_SEQUENCER_ORDER_TABLE):
return
@@ -628,7 +606,7 @@ def _highlight_master_cell_at(self, column: int) -> None:
)
def _tint_channel_rows(self) -> None:
- """Washes each channel row with a light tint of its identity colour.
+ """Washes each channel row with a light tint of its identity color.
Uses a row highlight so it sits on a layer beneath the position and cursor
highlights, which keep working; a cleared cursor cell falls back to the row
@@ -636,20 +614,20 @@ def _tint_channel_rows(self) -> None:
tint, sharing the fraction. A silenced channel trades that identity for a
neutral dark shade, so its row recedes as a whole.
"""
- for generator in GeneratorName.items():
+ for channel in ChannelName.items():
dpg.highlight_table_row(
TAG_SEQUENCER_ORDER_TABLE,
- self._table_row(generator),
- self._channel_row_tint(generator),
+ self._table_row(channel),
+ self._channel_row_tint(channel),
)
- def _channel_row_tint(self, generator: GeneratorName) -> ColorRGBA:
- if self._is_muted(generator):
+ def _channel_row_tint(self, channel: ChannelName) -> ColorRGBA:
+ if self._is_muted(channel):
return self._layout.colors.muted.background.rgba
- channel = channel_color(self._layout.colors.channels, generator)
+ tint_color = self._channel_colors.for_channel(channel)
return FadedColor(
- color=channel,
+ color=tint_color,
fraction=self._layout.tracker.channel_column_tint,
).rgba
@@ -685,15 +663,15 @@ def _clear_column_highlight(self) -> None:
def _build_row(
self,
- generator: Optional[GeneratorName],
+ channel: Optional[ChannelName],
position_count: int,
) -> None:
- font = Font.MONO_BOLD_SMALL if generator is None else Font.MONO_SMALL
+ font = Font.MONO_BOLD_SMALL if channel is None else Font.MONO_SMALL
row_id = dpg.add_table_row(parent=TAG_SEQUENCER_ORDER_TABLE)
- self._add_row_label(row_id, generator)
+ self._add_row_label(row_id, channel)
for position in range(position_count):
cell = dpg.add_table_cell(parent=row_id)
- key: OrderKey = (generator, position)
+ key: OrderKey = (channel, position)
selectable = dpg.add_selectable(
parent=cell,
label=self._render_cell(key),
@@ -708,7 +686,7 @@ def _build_row(
def _add_row_label(
self,
row_id: Sender,
- generator: Optional[GeneratorName],
+ channel: Optional[ChannelName],
) -> None:
"""Places one clickable row label: a channel's mute target, or the master target.
@@ -719,17 +697,17 @@ def _add_row_label(
label_cell = dpg.add_table_cell(parent=row_id)
label = dpg.add_selectable(
parent=label_cell,
- label=self._row_labels[generator],
- user_data=generator,
+ label=self._row_labels[channel],
+ user_data=channel,
callback=self._on_label_clicked,
)
FontRegistry.bind_to_item(label, Font.MONO_BOLD_SMALL)
dpg.bind_item_handler_registry(label, self._label_handler_tag)
show_tooltip(
label,
- self._tooltip_label_master if generator is None else self._tooltip_label_channel,
+ self._tooltip_label_master if channel is None else self._tooltip_label_channel,
)
- self._label_rows[label] = generator
+ self._label_rows[label] = channel
def _build_divider_row(self, position_count: int) -> None:
"""Inserts the thin rule that sets the master row apart from the channel
@@ -747,7 +725,7 @@ def _build_divider_row(self, position_count: int) -> None:
def _render_cell(self, key: OrderKey) -> str:
cursor = self._input_state.cursor
stored = self._order.values.get(key, _EMPTY_LABEL)
- if cursor is not None and (cursor.generator, cursor.position) == key:
+ if cursor is not None and (cursor.channel, cursor.position) == key:
return pending_label(
self._input_state.pending,
stored,
@@ -756,15 +734,15 @@ def _render_cell(self, key: OrderKey) -> str:
return stored
- def _table_row(self, generator: Optional[GeneratorName]) -> int:
- if generator is None:
+ def _table_row(self, channel: Optional[ChannelName]) -> int:
+ if channel is None:
return MASTER_TABLE_ROW
- return CHANNEL_AXIS.index(generator) + 1
+ return CHANNEL_AXIS.index(channel) + 1
def _apply_cursor_highlight(self, cursor: OrderCursor) -> None:
dpg.highlight_table_cell(
TAG_SEQUENCER_ORDER_TABLE,
- self._table_row(cursor.generator),
+ self._table_row(cursor.channel),
cursor.position + 1,
color=self._layout.colors.cell_cursor.rgba,
)
@@ -777,10 +755,10 @@ def _selected_cells(self) -> FrozenSet[OrderKey]:
return frozenset()
keys: Set[OrderKey] = set()
- for generator in region.generators:
+ for channel in region.channels:
for position in region.positions:
if position < self._position_count:
- keys.add((generator, position))
+ keys.add((channel, position))
return frozenset(keys)
@@ -790,12 +768,12 @@ def _clear_cursor_highlight(self) -> None:
cursor = self._highlighted
self._highlighted = None
- if cursor.generator is None:
+ if cursor.channel is None:
self._highlight_master_cell_at(cursor.position + 1)
else:
dpg.unhighlight_table_cell(
TAG_SEQUENCER_ORDER_TABLE,
- self._table_row(cursor.generator),
+ self._table_row(cursor.channel),
cursor.position + 1,
)
@@ -813,7 +791,7 @@ def _restore_cursor(self) -> None:
return
position = min(cursor.position, self._position_count - 1)
- clamped = OrderCursor(cursor.generator, position)
+ clamped = OrderCursor(cursor.channel, position)
self._input_state = OrderInputState(cursor=clamped)
self._apply_cursor_highlight(clamped)
self._apply_column_highlight(clamped.position, focused=True)
@@ -855,7 +833,7 @@ def _apply_state(
self._refresh_remove_enabled()
def _update_cell_display(self, cursor: OrderCursor) -> None:
- key: OrderKey = (cursor.generator, cursor.position)
+ key: OrderKey = (cursor.channel, cursor.position)
widget = self._order.widget(key)
if widget is not None:
dpg.configure_item(widget, label=self._render_cell(key))
@@ -867,8 +845,8 @@ def _update_caret(self) -> None:
CaretOverlay.clear(TAG_SEQUENCER_ORDER_TABLE)
return
- key: OrderKey = (cursor.generator, cursor.position)
- font = Font.MONO_BOLD_SMALL if cursor.generator is None else Font.MONO_SMALL
+ key: OrderKey = (cursor.channel, cursor.position)
+ font = Font.MONO_BOLD_SMALL if cursor.channel is None else Font.MONO_SMALL
CaretOverlay.set_target(
owner=TAG_SEQUENCER_ORDER_TABLE,
widget=self._order.widget(key),
@@ -892,14 +870,15 @@ def _on_cell_clicked(
return
state = self._committed_state()
- generator, position = user_data
- cursor = OrderCursor(generator, position)
+ channel, position = user_data
+ cursor = OrderCursor(channel, position)
if Modifier.SHIFT in capture_modifiers():
self._apply_state(state.extend_to(cursor))
return
self._apply_state(OrderInputState(cursor=cursor))
+ # TODO: to abstract
def _on_cell_held(self, _sender: Sender, app_data: Sender) -> None:
"""Carries the selection to the cell under a held pointer, which is what drags a range out.
@@ -960,21 +939,21 @@ def _cell_at(self) -> Optional[OrderKey]:
return (self._generator_at(top), position)
- def _generator_at(self, top: float) -> Optional[GeneratorName]:
+ def _generator_at(self, top: float) -> Optional[ChannelName]:
"""Which channel row stands at a height, the master row reading ``None``.
The master row stands apart from the channels beneath it, so the walk asks each row where
it was drawn and takes the first one reaching past the pointer.
"""
- for generator in CHANNEL_AXIS:
- widget = self._order.widget((generator, 0))
+ for channel in CHANNEL_AXIS:
+ widget = self._order.widget((channel, 0))
if widget is None:
continue
_, row_top = dpg.get_item_rect_min(widget)
_, row_height = dpg.get_item_rect_size(widget)
if top < row_top + row_height:
- return generator
+ return channel
return CHANNEL_AXIS[-1]
@@ -1022,14 +1001,14 @@ def _on_cell_right_clicked(
if key is None:
return
- generator, position = key
- self._show_context_menu(generator, position)
+ channel, position = key
+ self._menu.show_for_cell(channel, position)
def _on_label_clicked(
self,
sender: Sender,
_app_data: bool,
- user_data: Optional[GeneratorName],
+ user_data: Optional[ChannelName],
) -> None:
self._channel_switch.click(sender, user_data)
@@ -1050,170 +1029,46 @@ def _on_label_right_clicked(
if clicked_item not in self._label_rows:
return
- self._show_channel_menu(self._label_rows[clicked_item])
-
- def _show_channel_menu(self, generator: Optional[GeneratorName]) -> None:
- """Opens the menu behind a row label, titled with the row's own name."""
- with context_menu():
- header = dpg.add_text(self._row_labels[generator])
- FontRegistry.bind_to_item(header, Font.MONO_BOLD)
- dpg.add_separator()
- self._channel_switch.add_menu_items(
- generator,
- self._current_channels,
- )
-
- def _show_context_menu(
- self,
- generator: Optional[GeneratorName],
- position: int,
- ) -> None:
- target = self._surface.target_at(OrderCursor(generator, position))
- with context_menu():
- header = dpg.add_text(
- cell_title(
- position,
- self._row_labels[generator],
- )
- )
- FontRegistry.bind_to_item(header, Font.MONO_BOLD)
- dpg.add_separator()
- add_play_menu_item(
- self._lbl_context_play,
- lambda: self.call(self.on_play_from_requested, position),
- shortcut=self._shortcuts.display(ShortcutId.PLAY_FROM_FRAME),
- )
- dpg.add_separator()
- self.add_action_items(target)
+ self._menu.show_for_channel(self._label_rows[clicked_item])
+ # TODO: to abstract
@property
def edit_surface(self) -> OrderEditSurface:
"""This table as the menu bar's Edit menu reaches it."""
return self._surface
+ # TODO: to abstract
def input_state(self) -> OrderInputState:
"""Where the cursor stands and what it has selected, which a target is resolved from."""
return self._input_state
+ # TODO: to abstract
def owns_keys(self) -> bool:
"""Whether the table owns the next key, which is also what the Edit menu asks."""
return self._keys_active()
- def add_action_items(self, target: OrderTarget) -> None:
- """Builds every action an order cell offers, in the order each menu prints them.
-
- The table states its actions once, and whoever asks for them decides where they are shown:
- the cell menu asks for the cell a pointer landed on, and the menu bar asks for the cell the
- cursor stands on. An action added here reaches both.
- """
- self._add_select_items(target.cell)
- dpg.add_separator()
- self._surface.add_block_items(target)
- dpg.add_separator()
- self._add_frame_items(target.cell.position)
- dpg.add_separator()
- self._add_move_items(target.cell.position)
-
- def _add_select_items(self, cell: OrderCursor) -> None:
- """Builds the two shapes a selection takes, the whole order and one row of it.
-
- Each item fires the gesture its key fires, on the cell the menu names: a row selected from
- a cell menu is the row that cell stands in, and one selected from the menu bar is the row
- the cursor stands in.
- """
- dpg.add_menu_item(
- label=self._lbl_context_select_all,
- shortcut=self._shortcuts.display(ShortcutId.ORDER_SELECT_ALL),
- callback=lambda: self._select_shape(
- ShortcutId.ORDER_SELECT_ALL,
- cell,
- ),
- )
- dpg.add_menu_item(
- label=self._lbl_context_select_row,
- shortcut=self._shortcuts.display(ShortcutId.ORDER_SELECT_ROW),
- callback=lambda: self._select_shape(
- ShortcutId.ORDER_SELECT_ROW,
- cell,
- ),
- )
+ @property
+ def channel_switch(self) -> ChannelSwitch:
+ """The switch a row label's click and menu act through."""
+ return self._channel_switch
- def _add_frame_items(self, position: int) -> None:
- """Builds the frame operations, each acting on the whole frame the target cell sits in."""
- dpg.add_menu_item(
- label=self._lbl_context_duplicate,
- shortcut=self._shortcuts.display(ShortcutId.ORDER_DUPLICATE_FRAME),
- callback=lambda: self.call(self.on_duplicate_requested, position),
- )
- dpg.add_menu_item(
- label=self._lbl_context_clone,
- shortcut=self._shortcuts.display(ShortcutId.ORDER_CLONE_FRAME),
- callback=lambda: self.call(self.on_clone_requested, position),
- )
- dpg.add_menu_item(
- label=self._lbl_context_insert,
- shortcut=self._shortcuts.display(ShortcutId.ORDER_INSERT_FRAME),
- callback=lambda: self.call(self.on_insert_requested, position),
- )
- dpg.add_menu_item(
- label=self._lbl_context_clear,
- shortcut=self._shortcuts.display(ShortcutId.ORDER_CLEAR_FRAME),
- callback=lambda: self.call(self.on_clear_requested, position),
- )
- dpg.add_menu_item(
- label=self._lbl_context_remove,
- shortcut=self._shortcuts.display(ShortcutId.ORDER_REMOVE_FRAME),
- callback=lambda: self.call(self.on_remove_requested, position),
- )
+ @property
+ def channels(self) -> Optional[SequencerChannelsViewModel]:
+ """Which channels stand silenced, as the last view the panel was given states it."""
+ return self._current_channels
- def _add_move_items(self, position: int) -> None:
- """Builds the four moves a frame can make, in the order they walk the song."""
- self._add_move_item(
- self._lbl_context_move_left,
- ShortcutId.ORDER_MOVE_FRAME_LEFT,
- position,
- )
- self._add_move_item(
- self._lbl_context_move_right,
- ShortcutId.ORDER_MOVE_FRAME_RIGHT,
- position,
- )
- self._add_move_item(
- self._lbl_context_move_start,
- ShortcutId.ORDER_MOVE_FRAME_TO_START,
- position,
- )
- self._add_move_item(
- self._lbl_context_move_end,
- ShortcutId.ORDER_MOVE_FRAME_TO_END,
- position,
- )
+ @property
+ def position_count(self) -> int:
+ """How many frames the order holds, which is how far a move can carry one."""
+ return self._position_count
- def _add_move_item(
- self,
- label: str,
- shortcut_id: ShortcutId,
- position: int,
- ) -> None:
- """Adds a move item, greyed out (disabled) when the move would have no effect.
+ def row_label(self, channel: Optional[ChannelName]) -> str:
+ """The name a row carries, which its label and its menu title show."""
+ return self._row_labels[channel]
- The action names both the direction it moves and the accelerator it prints, so the item a
- reader sees is the one the key press performs.
- """
- target = MOVE_DIRECTIONS[shortcut_id].target(
- position,
- self._position_count,
- )
- dpg.add_menu_item(
- label=label,
- shortcut=self._shortcuts.display(shortcut_id),
- enabled=target is not None,
- callback=lambda: self.call(
- self.on_move_requested,
- position,
- target,
- ),
- )
+ def add_action_items(self, target: OrderTarget) -> None:
+ """Builds every action an order cell offers, which is what the menu bar's Edit group asks for."""
+ self._menu.add_action_items(target)
def _keys_active(self) -> bool:
"""Whether the order table owns the next key: its tab is in front, its cursor is set, and
@@ -1226,6 +1081,7 @@ def _keys_active(self) -> bool:
"""
return self._tab_active() and self._input_state.cursor is not None and not self._router.is_field_focused
+ # TODO: to extract common parts [_on_key_pressed]
def _on_key_pressed(self, event: KeyEvent) -> bool:
"""Applies an order key to the active cell, reporting whether the table consumed it.
@@ -1247,7 +1103,7 @@ def _on_key_pressed(self, event: KeyEvent) -> bool:
if self._extend_selection(shortcut_id):
return True
- if self._select_shape(shortcut_id, cursor):
+ if self.select_shape(shortcut_id, cursor):
return True
if self._block_action(shortcut_id):
@@ -1302,7 +1158,7 @@ def _extend_selection(self, shortcut_id: ShortcutId) -> bool:
return True
- def _select_shape(
+ def select_shape(
self,
shortcut_id: ShortcutId,
cell: OrderCursor,
@@ -1511,12 +1367,12 @@ def _emit(
if cursor is None:
return
- if cursor.generator is None:
+ if cursor.channel is None:
self.call(self.on_set_master_entry, cursor.position, index)
else:
self.call(
self.on_set_order_entry,
- cursor.generator,
+ cursor.channel,
cursor.position,
index,
)
@@ -1536,7 +1392,7 @@ def _get_removable_position(self) -> Optional[int]:
return None
def _refresh_remove_enabled(self) -> None:
- """Enables ``[-]`` while a press would remove a frame, so a greyed-out button tells the
+ """Enables ``[-]`` while a press would remove a frame, so a grayed-out button tells the
user that removal awaits a selected frame.
"""
if self._buttons is not None:
diff --git a/src/sampletones_application/ui/panels/sequencer/rows.py b/src/sampletones_application/ui/panels/sequencer/rows.py
index c34b19d4f..a989d5967 100644
--- a/src/sampletones_application/ui/panels/sequencer/rows.py
+++ b/src/sampletones_application/ui/panels/sequencer/rows.py
@@ -22,7 +22,7 @@ def group_color(
settings: SequencerSettingsViewModel,
colors: SequencerColors,
) -> Optional[BaseColor]:
- """The emphasis a row takes from the group the project's metre opens on it.
+ """The emphasis a row takes from the group the project's meter opens on it.
The second highlight marks the bar and the first the beat, so a row opening a bar takes
the stronger of the two shades even where a beat opens there as well. A row inside a beat
@@ -62,7 +62,7 @@ def row_background(
colors: SequencerColors,
cues: RowCues,
) -> Optional[BaseColor]:
- """The colour a pattern row's background carries, group and cue taken together.
+ """The color a pattern row's background carries, group and cue taken together.
DearPyGui offers one row background above the zebra stripe, so the row's standing
emphasis and whatever mark is passing over it arrive as a single shade: the cue is
diff --git a/src/sampletones_application/ui/panels/sequencer/samples.py b/src/sampletones_application/ui/panels/sequencer/samples.py
deleted file mode 100644
index 6814c5b02..000000000
--- a/src/sampletones_application/ui/panels/sequencer/samples.py
+++ /dev/null
@@ -1,685 +0,0 @@
-from dataclasses import dataclass
-from typing import Callable, Dict, Final, List, Optional, Tuple
-
-import dearpygui.dearpygui as dpg
-
-from sampletones_application.categories.context import channel_label, context_label, context_text
-from sampletones_application.categories.elements.global_ import ContextElements
-from sampletones_application.categories.elements.sequencer import (
- SequencerInstrumentsElements,
-)
-from sampletones_application.categories.hierarchy import Page, Panel, TextType
-from sampletones_application.categories.manager import LanguageManager
-from sampletones_application.layout.tabs.sequencer import SequencerLayout
-from sampletones_application.tags.compose import compose_tag
-from sampletones_application.tags.general import SUF_HANDLER_REGISTRY
-from sampletones_application.tags.sequencer import (
- TAG_SEQUENCER_INSTRUMENTS_INPUT_RENAME,
- TAG_SEQUENCER_INSTRUMENTS_PANEL,
- TAG_SEQUENCER_INSTRUMENTS_TABLE,
- TAG_SEQUENCER_INSTRUMENTS_THEME_ROW,
- TAG_SEQUENCER_INSTRUMENTS_WINDOW,
-)
-from sampletones_application.ui.elements.context_menu import (
- add_detail_items,
- add_play_menu_item,
- context_menu,
-)
-from sampletones_application.ui.elements.fonts.font import Font
-from sampletones_application.ui.elements.fonts.registry import FontRegistry
-from sampletones_application.ui.elements.panel import GUIPanel
-from sampletones_application.ui.themes.registry import ThemeRegistry
-from sampletones_application.utils.gui.dpg import dpg_delete_children
-from sampletones_application.utils.gui.frame import FrameCallbackManager
-from sampletones_application.utils.gui.keyboard import (
- PRIORITY_PANEL,
- ActivePredicate,
- KeyEvent,
- KeyRouter,
-)
-from sampletones_application.utils.gui.shortcuts.ids import ShortcutCategory, ShortcutId
-from sampletones_application.utils.gui.shortcuts.source import ShortcutSource
-from sampletones_application.utils.palette.colors.base import BaseColor
-from sampletones_application.view_model.sequencer.move import MoveDirection
-from sampletones_application.view_model.sequencer.samples import (
- SampleEntryViewModel,
- SampleSelection,
- SequencerSamplesViewModel,
-)
-from sampletones_application.view_model.shared.footprint import SampleFootprintViewModel
-from sampletones_core.constants.enums import GeneratorName
-from sampletones_core.utils.display import display_id
-from sampletones_shared.types.application import Sender
-from sampletones_shared.types.callback import StringCallback
-
-FROZEN_HEADER_ROWS: Final[int] = 1
-
-
-@dataclass(frozen=True)
-class SampleMove:
- """One of the four moves, as its key press and its menu item each name it."""
-
- element: SequencerInstrumentsElements
- shortcut: ShortcutId
- direction: MoveDirection
-
-
-SAMPLE_MOVES: Final[Tuple[SampleMove, ...]] = (
- SampleMove(
- element=SequencerInstrumentsElements.CONTEXT_MOVE_UP,
- shortcut=ShortcutId.SAMPLES_MOVE_SAMPLE_UP,
- direction=MoveDirection.PREVIOUS,
- ),
- SampleMove(
- element=SequencerInstrumentsElements.CONTEXT_MOVE_DOWN,
- shortcut=ShortcutId.SAMPLES_MOVE_SAMPLE_DOWN,
- direction=MoveDirection.NEXT,
- ),
- SampleMove(
- element=SequencerInstrumentsElements.CONTEXT_MOVE_TOP,
- shortcut=ShortcutId.SAMPLES_MOVE_SAMPLE_TO_TOP,
- direction=MoveDirection.FIRST,
- ),
- SampleMove(
- element=SequencerInstrumentsElements.CONTEXT_MOVE_BOTTOM,
- shortcut=ShortcutId.SAMPLES_MOVE_SAMPLE_TO_BOTTOM,
- direction=MoveDirection.LAST,
- ),
-)
-
-MOVE_DIRECTIONS: Final[Dict[ShortcutId, MoveDirection]] = {move.shortcut: move.direction for move in SAMPLE_MOVES}
-
-
-class GUISequencerSamplesPanel(GUIPanel):
- def __init__(
- self,
- *,
- layout: SequencerLayout,
- detail_color: BaseColor,
- language_manager: LanguageManager,
- key_router: KeyRouter,
- tab_active: ActivePredicate,
- shortcut_source: ShortcutSource,
- initial_collapsed: bool = False,
- ) -> None:
- self._language_manager = language_manager
- self._layout = layout
- self._detail_color = detail_color
- self._router = key_router
- self._tab_active = tab_active
- self._shortcuts = shortcut_source
- self._row_handler_tag = compose_tag(TAG_SEQUENCER_INSTRUMENTS_TABLE, SUF_HANDLER_REGISTRY)
- self._rename_handler_tag = compose_tag(TAG_SEQUENCER_INSTRUMENTS_INPUT_RENAME, SUF_HANDLER_REGISTRY)
- self._selected_sample_id: Optional[str] = None
- self._selected_row: Optional[int] = None
- self._editing_sample_id: Optional[str] = None
- self._entries: Tuple[SampleEntryViewModel, ...] = ()
- self._lbl_sample_size = context_label(language_manager, ContextElements.SAMPLE_SIZE)
- self._tpl_size_bytes = context_text(language_manager, TextType.TEMPLATE, ContextElements.SIZE_BYTES)
- self._tip_size_bytes = context_text(language_manager, TextType.TOOLTIP, ContextElements.SIZE_BYTES)
- self.sample_footprint: Optional[Callable[[str], Optional[SampleFootprintViewModel]]] = None
- self.on_sample_selected: Optional[StringCallback] = None
- self.on_sample_edit_requested: Optional[StringCallback] = None
- self.on_loop_changed: Optional[Callable[[str, bool], None]] = None
- self.on_remove_requested: Optional[StringCallback] = None
- self.on_play_requested: Optional[StringCallback] = None
- self.on_move_requested: Optional[Callable[[str, int], None]] = None
- self.on_rename_committed: Optional[Callable[[str, str], None]] = None
- self.on_duplicate_requested: Optional[StringCallback] = None
-
- super().__init__(
- tag=TAG_SEQUENCER_INSTRUMENTS_PANEL,
- width=-1,
- height=-layout.history.height,
- )
- self._enable_vertical_collapse(initial_collapsed=initial_collapsed)
-
- def create_panel(self, parent: str) -> None:
- with self._collapsible_card(
- parent,
- self._label(self._language_manager, SequencerInstrumentsElements.INSTRUMENTS_TEXT),
- glyph=self._glyphs.headers.samples,
- ):
- self._create_samples_table()
-
- self._create_row_handlers()
- self._create_rename_handler()
- self._create_key_handler()
-
- def _create_row_handlers(self) -> None:
- with dpg.item_handler_registry(tag=self._row_handler_tag):
- dpg.add_item_clicked_handler(callback=self._on_sample_clicked)
- dpg.add_item_double_clicked_handler(callback=self._on_sample_double_clicked)
-
- def _create_rename_handler(self) -> None:
- with dpg.item_handler_registry(tag=self._rename_handler_tag):
- dpg.add_item_deactivated_handler(callback=self._on_rename_deactivated)
-
- def _create_key_handler(self) -> None:
- self._router.register(
- self._on_key_pressed,
- priority=PRIORITY_PANEL,
- active=self._keys_active,
- )
-
- def _create_samples_table(self) -> None:
- with (
- dpg.child_window(
- tag=TAG_SEQUENCER_INSTRUMENTS_WINDOW,
- border=False,
- width=-1,
- height=-1,
- ),
- dpg.table(
- tag=TAG_SEQUENCER_INSTRUMENTS_TABLE,
- width=-1,
- height=-1,
- header_row=True,
- resizable=False,
- borders_innerH=False,
- borders_innerV=True,
- borders_outerH=True,
- borders_outerV=True,
- scrollY=True,
- freeze_rows=FROZEN_HEADER_ROWS,
- row_background=True,
- policy=dpg.mvTable_SizingFixedFit,
- ),
- ):
- dpg.add_table_column(
- label=self._label(
- self._language_manager,
- SequencerInstrumentsElements.COLUMN_ID,
- ),
- width_fixed=True,
- init_width_or_weight=self._layout.table_cells.instrument.id,
- )
- dpg.add_table_column(
- label=self._label(
- self._language_manager,
- SequencerInstrumentsElements.COLUMN_NAME,
- ),
- width_stretch=True,
- init_width_or_weight=self._layout.table_cells.instrument.name,
- )
- dpg.add_table_column(
- label=self._label(
- self._language_manager,
- SequencerInstrumentsElements.COLUMN_LOOP,
- ),
- width_fixed=True,
- init_width_or_weight=self._layout.table_cells.instrument.loop,
- )
- ThemeRegistry.get(TAG_SEQUENCER_INSTRUMENTS_THEME_ROW).bind_to_item(TAG_SEQUENCER_INSTRUMENTS_TABLE)
-
- def update_view(self, view_model: SequencerSamplesViewModel) -> None:
- self._entries = view_model.samples
- self._editing_sample_id = None
- self._rebuild()
-
- def _rebuild(self) -> None:
- """Rebuilds the samples table from the cached entries with explicit parents.
-
- Items pass an explicit ``parent`` so each widget binds directly: the browser
- tree builds on a worker thread and the DearPyGui container stack is
- process-global, so explicit parents keep this build independent of that
- shared stack.
- """
- dpg_delete_children(TAG_SEQUENCER_INSTRUMENTS_TABLE, slot=1)
- self._selected_row = None
- for position, entry in enumerate(self._entries):
- self._build_sample_row(position, entry)
- if self._selected_row is None:
- self._selected_sample_id = None
-
- def _build_sample_row(
- self,
- position: int,
- entry: SampleEntryViewModel,
- ) -> None:
- row_id = dpg.add_table_row(parent=TAG_SEQUENCER_INSTRUMENTS_TABLE)
- self._build_id_cell(row_id, position, entry)
- self._build_name_cell(row_id, position, entry)
- self._build_loop_cell(row_id, entry)
- if entry.sample_id == self._selected_sample_id:
- self._selected_row = position
- self._highlight_selected_row(position)
-
- def _highlight_selected_row(self, position: int) -> None:
- dpg.highlight_table_row(
- TAG_SEQUENCER_INSTRUMENTS_TABLE,
- position,
- color=self._layout.colors.cell_cursor.rgba,
- )
-
- def repaint(self) -> None:
- """Issues the selected row's tint again so it takes the palette now in place.
-
- DearPyGui keeps a row highlight on the table rather than on an item, so the colour
- reaches it only by being pushed again.
- """
- if self._selected_row is None or not dpg.does_item_exist(TAG_SEQUENCER_INSTRUMENTS_TABLE):
- return
-
- self._highlight_selected_row(self._selected_row)
-
- def _build_id_cell(
- self,
- row_id: int | str,
- position: int,
- entry: SampleEntryViewModel,
- ) -> None:
- id_cell = dpg.add_table_cell(parent=row_id)
- id_selectable = dpg.add_selectable(
- parent=id_cell,
- label=display_id(position),
- user_data=(position, entry.sample_id),
- callback=self._on_sample_selected,
- )
- FontRegistry.bind_to_item(id_selectable, Font.MONO_SMALL)
- dpg.bind_item_handler_registry(id_selectable, self._row_handler_tag)
-
- def _build_name_cell(
- self,
- row_id: int | str,
- position: int,
- entry: SampleEntryViewModel,
- ) -> None:
- name_cell = dpg.add_table_cell(parent=row_id)
- if entry.sample_id == self._editing_sample_id:
- self._build_name_input(name_cell, entry)
- else:
- self._build_name_selectable(name_cell, position, entry)
-
- def _build_name_selectable(
- self,
- name_cell: int | str,
- position: int,
- entry: SampleEntryViewModel,
- ) -> None:
- name_selectable = dpg.add_selectable(
- parent=name_cell,
- label=entry.name,
- user_data=(position, entry.sample_id),
- callback=self._on_sample_selected,
- )
- FontRegistry.bind_to_item(name_selectable, Font.MONO_SMALL)
- dpg.bind_item_handler_registry(name_selectable, self._row_handler_tag)
-
- def _build_name_input(
- self,
- name_cell: int | str,
- entry: SampleEntryViewModel,
- ) -> None:
- name_input = dpg.add_input_text(
- tag=TAG_SEQUENCER_INSTRUMENTS_INPUT_RENAME,
- parent=name_cell,
- default_value=entry.name,
- width=-1,
- on_enter=True,
- callback=self._on_rename_enter,
- )
- FontRegistry.bind_to_item(name_input, Font.MONO_SMALL)
- dpg.bind_item_handler_registry(name_input, self._rename_handler_tag)
-
- def _build_loop_cell(
- self,
- row_id: int | str,
- entry: SampleEntryViewModel,
- ) -> None:
- loop_cell = dpg.add_table_cell(parent=row_id)
- loop_checkbox = dpg.add_checkbox(
- parent=loop_cell,
- default_value=entry.loop,
- user_data=entry.sample_id,
- callback=self._on_loop_toggled,
- )
- FontRegistry.bind_to_item(loop_checkbox, Font.REGULAR_SMALL)
-
- def _on_sample_selected(
- self,
- sender: Sender,
- _app_data: bool,
- user_data: Tuple[int, str],
- ) -> None:
- position, sample_id = user_data
- dpg.set_value(sender, False)
- if self._selected_row is not None:
- dpg.unhighlight_table_row(
- TAG_SEQUENCER_INSTRUMENTS_TABLE,
- self._selected_row,
- )
-
- self._selected_row = position
- self._selected_sample_id = sample_id
- self._highlight_selected_row(position)
- self.call(self.on_sample_selected, sample_id)
-
- @property
- def selection(self) -> Optional[SampleSelection]:
- """The selected sample, or ``None`` while the panel holds no selection.
-
- Derived from the highlighted row and the cached entries on each read, so it reports
- whatever the table currently shows. Lets an operation hosted by another panel of the tab
- address the selection without keeping a copy of it.
- """
- if self._selected_sample_id is None or self._selected_row is None:
- return None
-
- entry = self._entry_for(self._selected_sample_id)
- if entry is None:
- return None
-
- return SampleSelection(
- sample_id=entry.sample_id,
- position=self._selected_row,
- name=entry.name,
- )
-
- def deselect(self) -> None:
- """Drops the sample selection so the panel stops consuming keystrokes.
-
- Mirrors the grid and order panels: each registers a key-router scope that is active only
- while it holds a selection, so a single selection across the three decides which one acts
- on a keystroke. Selecting a cell in another panel clears this one's selection via this method.
- """
- if self._selected_row is not None:
- dpg.unhighlight_table_row(
- TAG_SEQUENCER_INSTRUMENTS_TABLE,
- self._selected_row,
- )
-
- self._selected_row = None
- self._selected_sample_id = None
-
- def _keys_active(self) -> bool:
- """Whether the samples panel owns the next key.
-
- The panel answers only while its tab is in front, since a selection outlives a move to
- another tab. There, a name being edited keeps the keyboard so Escape can cancel the rename;
- otherwise the panel acts when a sample is selected and no field holds the keyboard. A modal
- dialog claims keys at a higher priority in the router, so the panel needs no modal check.
- """
- if not self._tab_active():
- return False
-
- if self._editing_sample_id is not None:
- return True
-
- return self._selected_sample_id is not None and not self._router.is_field_focused
-
- def _on_key_pressed(self, event: KeyEvent) -> bool:
- """Applies a samples key to the selected sample, reporting whether the panel consumed it.
-
- The scheme says which press each samples action answers to; a press the samples category
- leaves unnamed goes to the application's global shortcuts.
- """
- shortcut_id = self._shortcuts.action(ShortcutCategory.SAMPLES, event)
- if self._editing_sample_id is not None:
- return self._cancel_edit(shortcut_id)
-
- sample_id = self._selected_sample_id
- if sample_id is None or shortcut_id is None:
- return False
-
- if self._move_sample(shortcut_id):
- return True
-
- match shortcut_id:
- case ShortcutId.SAMPLES_REMOVE_SAMPLE:
- self.call(self.on_remove_requested, sample_id)
- case ShortcutId.SAMPLES_RENAME_SAMPLE:
- self._start_rename(sample_id)
- case _:
- return False
-
- return True
-
- def _cancel_edit(self, shortcut_id: Optional[ShortcutId]) -> bool:
- """Drops the name being edited, reporting whether the press was the cancel.
-
- A rename in progress keeps every other key for the input, so typing a name reaches the
- field rather than the panel.
- """
- if shortcut_id is not ShortcutId.SAMPLES_CANCEL_RENAME:
- return False
-
- self._cancel_rename()
- return True
-
- def _move_sample(self, shortcut_id: ShortcutId) -> bool:
- """Moves the selected sample up, down, to the top or to the bottom of the list.
-
- Returns whether the action was one of the moves, so a boundary with nowhere to go still
- counts as consumed and stays out of the global shortcuts.
- """
- direction = MOVE_DIRECTIONS.get(shortcut_id)
- if direction is None or self._selected_sample_id is None or self._selected_row is None:
- return False
-
- target = direction.target(self._selected_row, len(self._entries))
- if target is not None:
- self.call(self.on_move_requested, self._selected_sample_id, target)
-
- return True
-
- def _start_rename(self, sample_id: str) -> None:
- """Turns the sample's name cell into a focused text input."""
- if self._entry_for(sample_id) is None:
- return
-
- self._editing_sample_id = sample_id
- self._rebuild()
- FrameCallbackManager.set_frame_callback(lambda: dpg.focus_item(TAG_SEQUENCER_INSTRUMENTS_INPUT_RENAME))
-
- def _commit_rename(self) -> None:
- """Applies the edited name and restores the read-only cell.
-
- Clears the edit before notifying so the input's deactivated handler, fired
- during the teardown rebuild, sees the edit already finished.
- """
- if self._editing_sample_id is None:
- return
-
- sample_id = self._editing_sample_id
- name = dpg.get_value(TAG_SEQUENCER_INSTRUMENTS_INPUT_RENAME)
- self._editing_sample_id = None
- self.call(self.on_rename_committed, sample_id, name)
- self._rebuild()
-
- def _cancel_rename(self) -> None:
- if self._editing_sample_id is None:
- return
-
- self._editing_sample_id = None
- self._rebuild()
-
- def _on_rename_enter(self, _sender: Sender, _app_data: str) -> None:
- self._commit_rename()
-
- def _on_rename_deactivated(self, _sender: Sender, _app_data: int) -> None:
- self._commit_rename()
-
- def _on_loop_toggled(
- self,
- _sender: Sender,
- app_data: bool,
- user_data: str,
- ) -> None:
- self.call(
- self.on_loop_changed,
- user_data,
- app_data,
- )
-
- def _on_sample_double_clicked(
- self,
- _sender: Sender,
- app_data: List[int],
- ) -> None:
- clicked_item = app_data[1]
- user_data = dpg.get_item_user_data(clicked_item)
- if user_data is not None:
- _, sample_id = user_data
- self.call(self.on_sample_edit_requested, sample_id)
-
- def _on_sample_clicked(
- self,
- _sender: Sender,
- app_data: Tuple[int, int],
- ) -> None:
- mouse_button, clicked_item = app_data
- if mouse_button != dpg.mvMouseButton_Right:
- return
-
- user_data = dpg.get_item_user_data(clicked_item)
- if user_data is None:
- return
-
- position, sample_id = user_data
- self._show_context_menu(position, sample_id)
-
- def _entry_for(self, sample_id: str) -> Optional[SampleEntryViewModel]:
- return next((entry for entry in self._entries if entry.sample_id == sample_id), None)
-
- def _show_context_menu(self, position: int, sample_id: str) -> None:
- entry = self._entry_for(sample_id)
- if entry is None:
- return
-
- target = SampleSelection(
- sample_id=sample_id,
- position=position,
- name=entry.name,
- )
- with context_menu():
- header = dpg.add_text(target.label)
- FontRegistry.bind_to_item(header, Font.MONO_BOLD)
- add_detail_items(
- self._footprint_items(sample_id),
- color=self._detail_color,
- tooltip=self._tip_size_bytes,
- )
- dpg.add_separator()
- add_play_menu_item(
- context_label(self._language_manager, ContextElements.PLAY),
- lambda: self.call(
- self.on_play_requested,
- sample_id,
- ),
- )
- dpg.add_separator()
- self.add_action_items(target)
-
- def _footprint_items(self, sample_id: str) -> List[Tuple[str, str]]:
- """The byte figures the menu prints for a sample: its total, then each channel that plays.
-
- The figures are asked for as the menu opens, so they name what the sample occupies at the
- moment a reader looks. A channel standing by is written by no export, so it costs nothing
- and the menu names the channels that do.
- """
- footprint = self.query(self.sample_footprint, sample_id, default=None)
- if footprint is None:
- return []
-
- items = [(self._lbl_sample_size, self._format_size(footprint.total_bytes))]
- for generator_name in GeneratorName.items():
- instrument_bytes = footprint.bytes_for(generator_name)
- if instrument_bytes is not None:
- items.append(
- (
- channel_label(self._language_manager, generator_name),
- self._format_size(instrument_bytes),
- )
- )
-
- return items
-
- def _format_size(self, byte_count: int) -> str:
- return self._tpl_size_bytes.format(bytes=byte_count)
-
- def owns_edit_actions(self) -> bool:
- """Whether the Edit menu states this panel's actions, which it does while it holds a sample.
-
- The menu offers what the next press would reach, so the key scope decides it, and the
- selection those keys act on is the one the actions are built for.
- """
- return self._keys_active() and self.selection is not None
-
- def build_edit_actions(self) -> None:
- """Builds the panel's whole action set for the sample the selection holds."""
- selection = self.selection
- if selection is not None:
- self.add_action_items(selection)
-
- def add_action_items(self, target: SampleSelection) -> None:
- """Builds every action a sample offers, in the order each menu prints them.
-
- The panel states its actions once, and whoever asks for them decides where they are shown:
- the row menu asks for the sample a pointer landed on, and the menu bar asks for the one the
- selection holds. An action added here reaches both, printing the key it answers to.
- """
- dpg.add_menu_item(
- label=self._label(
- self._language_manager,
- SequencerInstrumentsElements.CONTEXT_EDIT,
- ),
- callback=lambda: self.call(self.on_sample_edit_requested, target.sample_id),
- )
- dpg.add_menu_item(
- label=self._label(
- self._language_manager,
- SequencerInstrumentsElements.CONTEXT_RENAME,
- ),
- shortcut=self._shortcuts.display(ShortcutId.SAMPLES_RENAME_SAMPLE),
- callback=lambda: self._start_rename(target.sample_id),
- )
- dpg.add_menu_item(
- label=self._label(
- self._language_manager,
- SequencerInstrumentsElements.CONTEXT_DUPLICATE,
- ),
- callback=lambda: self.call(self.on_duplicate_requested, target.sample_id),
- )
- dpg.add_separator()
- dpg.add_menu_item(
- label=self._label(
- self._language_manager,
- SequencerInstrumentsElements.CONTEXT_REMOVE,
- ),
- shortcut=self._shortcuts.display(ShortcutId.SAMPLES_REMOVE_SAMPLE),
- callback=lambda: self.call(self.on_remove_requested, target.sample_id),
- )
- dpg.add_separator()
- for move in SAMPLE_MOVES:
- self._add_move_item(move, target)
-
- def _add_move_item(
- self,
- move: SampleMove,
- target: SampleSelection,
- ) -> None:
- """Builds one move item, offered while the move carries the sample somewhere new."""
- position = move.direction.target(target.position, len(self._entries))
- dpg.add_menu_item(
- label=self._label(self._language_manager, move.element),
- shortcut=self._shortcuts.display(move.shortcut),
- enabled=position is not None,
- callback=lambda: self.call(
- self.on_move_requested,
- target.sample_id,
- position,
- ),
- )
-
- @staticmethod
- def _label(
- language_manager: LanguageManager,
- element: SequencerInstrumentsElements,
- ) -> str:
- return language_manager[
- Page.SEQUENCER,
- Panel.INSTRUMENTS,
- TextType.LABEL,
- element,
- ]
diff --git a/src/sampletones_application/ui/panels/sequencer/tracker/__init__.py b/src/sampletones_application/ui/panels/sequencer/tracker/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/src/sampletones_application/ui/panels/sequencer/tracker/adjust.py b/src/sampletones_application/ui/panels/sequencer/tracker/adjust.py
new file mode 100644
index 000000000..1d2dcd4ec
--- /dev/null
+++ b/src/sampletones_application/ui/panels/sequencer/tracker/adjust.py
@@ -0,0 +1,68 @@
+from typing import Callable, Dict, Final, Tuple
+
+from sampletones_application.categories.elements.sequencer import SequencerTrackerElements
+from sampletones_application.utils.gui.shortcuts.ids import ShortcutId
+from sampletones_application.view_model.sequencer.region import TrackerRegion
+from sampletones_core.constants.general import MAX_VOLUME
+from sampletones_shared.constants.music import OCTAVE_SEMITONES, SEMITONE_STEP
+from sampletones_shared.types.application import Sender
+
+VOLUME_FINE_STEP: Final[int] = 1
+VOLUME_COARSE_STEP: Final[int] = (MAX_VOLUME + 1) // 4
+
+AdjustAction = Tuple[SequencerTrackerElements, ShortcutId, int]
+AdjustMenuCallback = Callable[[Sender, None, Tuple[TrackerRegion, int]], None]
+
+TRANSPOSE_ACTIONS: Final[Tuple[AdjustAction, ...]] = (
+ (
+ SequencerTrackerElements.CONTEXT_TRANSPOSE_UP,
+ ShortcutId.TRACKER_TRANSPOSE_UP,
+ SEMITONE_STEP,
+ ),
+ (
+ SequencerTrackerElements.CONTEXT_TRANSPOSE_DOWN,
+ ShortcutId.TRACKER_TRANSPOSE_DOWN,
+ -SEMITONE_STEP,
+ ),
+ (
+ SequencerTrackerElements.CONTEXT_TRANSPOSE_OCTAVE_UP,
+ ShortcutId.TRACKER_TRANSPOSE_OCTAVE_UP,
+ OCTAVE_SEMITONES,
+ ),
+ (
+ SequencerTrackerElements.CONTEXT_TRANSPOSE_OCTAVE_DOWN,
+ ShortcutId.TRACKER_TRANSPOSE_OCTAVE_DOWN,
+ -OCTAVE_SEMITONES,
+ ),
+)
+
+VOLUME_ACTIONS: Final[Tuple[AdjustAction, ...]] = (
+ (
+ SequencerTrackerElements.CONTEXT_VOLUME_UP,
+ ShortcutId.TRACKER_VOLUME_UP,
+ VOLUME_FINE_STEP,
+ ),
+ (
+ SequencerTrackerElements.CONTEXT_VOLUME_DOWN,
+ ShortcutId.TRACKER_VOLUME_DOWN,
+ -VOLUME_FINE_STEP,
+ ),
+ (
+ SequencerTrackerElements.CONTEXT_VOLUME_UP_COARSE,
+ ShortcutId.TRACKER_VOLUME_UP_COARSE,
+ VOLUME_COARSE_STEP,
+ ),
+ (
+ SequencerTrackerElements.CONTEXT_VOLUME_DOWN_COARSE,
+ ShortcutId.TRACKER_VOLUME_DOWN_COARSE,
+ -VOLUME_COARSE_STEP,
+ ),
+)
+
+
+def _steps(actions: Tuple[AdjustAction, ...]) -> Dict[ShortcutId, int]:
+ return {shortcut_id: delta for _, shortcut_id, delta in actions}
+
+
+TRANSPOSE_STEPS: Final[Dict[ShortcutId, int]] = _steps(TRANSPOSE_ACTIONS)
+VOLUME_STEPS: Final[Dict[ShortcutId, int]] = _steps(VOLUME_ACTIONS)
diff --git a/src/sampletones_application/ui/panels/sequencer/tracker/callbacks.py b/src/sampletones_application/ui/panels/sequencer/tracker/callbacks.py
new file mode 100644
index 000000000..937d18bf4
--- /dev/null
+++ b/src/sampletones_application/ui/panels/sequencer/tracker/callbacks.py
@@ -0,0 +1,28 @@
+from typing import Callable, Optional, Tuple
+
+from sampletones_application.ui.panels.sequencer.grid.surface.edit import GridEditSurface
+from sampletones_application.ui.panels.sequencer.input.target import TrackerTarget
+from sampletones_application.ui.panels.sequencer.input.tracker import TrackerCursor
+from sampletones_application.view_model.sequencer.region import TrackerCell, TrackerRegion
+from sampletones_application.view_model.sequencer.subcolumn import SubColumn
+from sampletones_application.view_model.sequencer.voices import VoiceKind
+from sampletones_core.constants.enums import ChannelName
+from sampletones_shared.types.callback import VoidCallback
+
+OnClearRowCallback = Callable[[int, Optional[ChannelName]], None]
+OnClearSubcolumnCallback = Callable[[int, Optional[ChannelName], SubColumn], None]
+OnSetRowCallback = Callable[[int, Optional[ChannelName], Optional[str], Optional[int], Optional[int]], None]
+OnSetNoteOffCallback = Callable[[int, Optional[ChannelName]], None]
+OnNoteTypedCallback = Callable[[int, ChannelName, int], None]
+OnCellSelectedCallback = VoidCallback
+OnPlayFromRowCallback = Callable[[int], None]
+OnPlayFromFrameCallback = VoidCallback
+OnAdjustCallback = Callable[[TrackerRegion, int], None]
+OnChannelMuteToggledCallback = Callable[[ChannelName], None]
+OnChannelSoloedCallback = Callable[[ChannelName], None]
+OnBlockRegionCallback = Callable[[TrackerRegion], None]
+OnPasteBlockCallback = Callable[[TrackerCell], None]
+CanPasteBlockQuery = Callable[[], bool]
+
+TrackerEditSurface = GridEditSurface[TrackerCursor, TrackerRegion, TrackerCell, TrackerTarget]
+ThemeKey = Tuple[SubColumn, Optional[VoiceKind]]
diff --git a/src/sampletones_application/ui/panels/sequencer/tracker/menu.py b/src/sampletones_application/ui/panels/sequencer/tracker/menu.py
new file mode 100644
index 000000000..abf5d8327
--- /dev/null
+++ b/src/sampletones_application/ui/panels/sequencer/tracker/menu.py
@@ -0,0 +1,311 @@
+from typing import Dict, Optional, Protocol, Tuple
+
+import dearpygui.dearpygui as dpg
+
+from sampletones_application.categories.elements.sequencer import SequencerTrackerElements
+from sampletones_application.categories.hierarchy import Page, Panel, TextType
+from sampletones_application.categories.manager import LanguageManager
+from sampletones_application.ui.elements.context_menu import add_play_menu_item, context_menu
+from sampletones_application.ui.elements.fonts.font import Font
+from sampletones_application.ui.elements.fonts.registry import FontRegistry
+from sampletones_application.ui.panels.sequencer import display as tracker_display
+from sampletones_application.ui.panels.sequencer.channels import ChannelSwitch
+from sampletones_application.ui.panels.sequencer.input.target import TrackerTarget
+from sampletones_application.ui.panels.sequencer.input.tracker import TrackerCursor
+from sampletones_application.ui.panels.sequencer.tracker.adjust import (
+ TRANSPOSE_ACTIONS,
+ VOLUME_ACTIONS,
+ AdjustAction,
+ AdjustMenuCallback,
+)
+from sampletones_application.ui.panels.sequencer.tracker.callbacks import (
+ OnAdjustCallback,
+ OnClearRowCallback,
+ OnClearSubcolumnCallback,
+ OnPlayFromFrameCallback,
+ OnPlayFromRowCallback,
+ OnSetNoteOffCallback,
+ OnSetRowCallback,
+ TrackerEditSurface,
+)
+from sampletones_application.utils.gui.shortcuts.ids import ShortcutId
+from sampletones_application.utils.gui.shortcuts.source import ShortcutSource
+from sampletones_application.view_model.sequencer.channels import SequencerChannelsViewModel
+from sampletones_application.view_model.sequencer.kind import column_takes
+from sampletones_application.view_model.sequencer.region import TrackerRegion
+from sampletones_application.view_model.sequencer.subcolumn import SubColumn
+from sampletones_application.view_model.sequencer.voices import VoiceEntryViewModel
+from sampletones_core.constants.enums import ChannelName
+from sampletones_shared.types.application import Sender
+from sampletones_shared.utils.callbacks import CallbackMixin
+
+
+class TrackerMenuHost(Protocol):
+ """What the tracker panel states to the menus raised over its grid.
+
+ The hooks are the panel's own, so the coordinator keeps wiring them where it already does, and
+ a menu reads whichever answer stands at the moment it opens.
+ """
+
+ on_clear_row: Optional[OnClearRowCallback]
+ on_clear_subcolumn: Optional[OnClearSubcolumnCallback]
+ on_set_row: Optional[OnSetRowCallback]
+ on_set_note_off: Optional[OnSetNoteOffCallback]
+ on_play_from_row: Optional[OnPlayFromRowCallback]
+ on_play_from_frame: Optional[OnPlayFromFrameCallback]
+ on_adjust_transpose: Optional[OnAdjustCallback]
+ on_adjust_volume: Optional[OnAdjustCallback]
+
+ @property
+ def edit_surface(self) -> TrackerEditSurface: ...
+
+ @property
+ def channel_switch(self) -> ChannelSwitch: ...
+
+ @property
+ def channels(self) -> Optional[SequencerChannelsViewModel]: ...
+
+ @property
+ def voices(self) -> Tuple[VoiceEntryViewModel, ...]: ...
+
+ def column_label(self, channel: Optional[ChannelName]) -> str: ...
+
+ def select_shape(self, shortcut_id: ShortcutId, cell: TrackerCursor) -> bool: ...
+
+
+class TrackerMenu(CallbackMixin):
+ """Every menu the tracker grid offers, wherever a reader raised it.
+
+ A cell menu names the cell a pointer landed on and a header menu the column beneath it, while
+ the menu bar's **Edit** group asks for the cell the cursor stands on. The grid states its
+ actions once in :meth:`add_action_items`, so an action added there reaches every door.
+
+ What a menu offers about a cell is asked for as it opens — the pool a voice is picked from, the
+ columns a block covers — which keeps what a menu prints and what a click does one answer.
+ """
+
+ def __init__(
+ self,
+ panel: TrackerMenuHost,
+ *,
+ language_manager: LanguageManager,
+ shortcut_source: ShortcutSource,
+ ) -> None:
+ self._panel = panel
+ self._shortcuts = shortcut_source
+ self._lbl_play = self._label(language_manager, SequencerTrackerElements.CONTEXT_PLAY)
+ self._lbl_play_from_frame = self._label(language_manager, SequencerTrackerElements.CONTEXT_PLAY_FROM_FRAME)
+ self._lbl_select_all = self._label(language_manager, SequencerTrackerElements.CONTEXT_SELECT_ALL)
+ self._lbl_select_column = self._label(language_manager, SequencerTrackerElements.CONTEXT_SELECT_COLUMN)
+ self._lbl_select_subcolumn = self._label(language_manager, SequencerTrackerElements.CONTEXT_SELECT_SUBCOLUMN)
+ self._lbl_note_off = self._label(language_manager, SequencerTrackerElements.CONTEXT_NOTE_OFF)
+ self._lbl_set_voice = self._label(language_manager, SequencerTrackerElements.CONTEXT_SET_VOICE)
+ self._lbl_no_voices = self._label(language_manager, SequencerTrackerElements.CONTEXT_NO_VOICES)
+ self._lbl_clear_subcolumn = self._label(language_manager, SequencerTrackerElements.CONTEXT_CLEAR_SUBCOLUMN)
+ self._lbl_clear_cell = self._label(language_manager, SequencerTrackerElements.CONTEXT_CLEAR_CELL)
+ self._lbl_clear_row = self._label(language_manager, SequencerTrackerElements.CONTEXT_CLEAR_ROW)
+ self._lbl_adjust: Dict[SequencerTrackerElements, str] = {
+ element: self._label(language_manager, element) for element, _, _ in (*TRANSPOSE_ACTIONS, *VOLUME_ACTIONS)
+ }
+
+ @staticmethod
+ def _label(
+ language_manager: LanguageManager,
+ element: SequencerTrackerElements,
+ ) -> str:
+ return language_manager[
+ Page.SEQUENCER,
+ Panel.TRACKER,
+ TextType.LABEL,
+ element,
+ ]
+
+ def show_for_header(self, channel: Optional[ChannelName]) -> None:
+ """Opens the menu behind a column header, titled with the column's own name."""
+ with context_menu():
+ header = dpg.add_text(self._panel.column_label(channel))
+ FontRegistry.bind_to_item(header, Font.MONO_BOLD)
+ dpg.add_separator()
+ self._panel.channel_switch.add_menu_items(channel, self._panel.channels)
+
+ def show_for_cell(
+ self,
+ row_index: int,
+ channel: Optional[ChannelName],
+ subcolumn: SubColumn,
+ ) -> None:
+ """Opens the cell-operations menu, titled with the cell the pointer landed on."""
+ target = self._panel.edit_surface.target_at(TrackerCursor(row_index, channel, subcolumn))
+ with context_menu():
+ header = dpg.add_text(
+ tracker_display.cell_title(row_index, self._panel.column_label(channel)),
+ )
+ FontRegistry.bind_to_item(header, Font.MONO_BOLD)
+ dpg.add_separator()
+ add_play_menu_item(
+ self._lbl_play,
+ lambda: self.call(self._panel.on_play_from_row, row_index),
+ shortcut=self._shortcuts.display(ShortcutId.TRACKER_PLAY_FROM_ROW),
+ )
+ add_play_menu_item(
+ self._lbl_play_from_frame,
+ lambda: self.call(self._panel.on_play_from_frame),
+ shortcut=self._shortcuts.display(ShortcutId.PLAY_FROM_FRAME),
+ )
+ dpg.add_separator()
+ self.add_action_items(target)
+
+ def add_action_items(self, target: TrackerTarget) -> None:
+ """Builds every action a tracker cell offers, in the order each menu prints them.
+
+ The grid states its actions once, and whoever asks for them decides where they are shown:
+ the cell menu asks for the cell a pointer landed on, and the menu bar asks for the cell the
+ cursor stands on. An action added here reaches both.
+ """
+ self._add_select_items(target.cell)
+ dpg.add_separator()
+ self._panel.edit_surface.add_block_items(target)
+ dpg.add_separator()
+ self._add_voice_submenu(target.cell)
+ dpg.add_menu_item(
+ label=self._lbl_note_off,
+ callback=lambda: self.call(self._panel.on_set_note_off, target.cell.row, target.cell.channel),
+ )
+ dpg.add_separator()
+ self._add_transpose_items(target)
+ dpg.add_separator()
+ self._add_volume_items(target)
+ dpg.add_separator()
+ self._add_clear_items(target.cell)
+
+ def _add_select_items(self, cell: TrackerCursor) -> None:
+ """Builds the three shapes a selection takes, from the whole frame down to one subcolumn.
+
+ Each item fires the gesture its key fires, on the cell the menu names: a column selected
+ from a cell menu is the column that cell stands in, and one selected from the menu bar is
+ the column the cursor stands in.
+ """
+ dpg.add_menu_item(
+ label=self._lbl_select_all,
+ shortcut=self._shortcuts.display(ShortcutId.TRACKER_SELECT_ALL),
+ callback=lambda: self._panel.select_shape(ShortcutId.TRACKER_SELECT_ALL, cell),
+ )
+ dpg.add_menu_item(
+ label=self._lbl_select_column,
+ shortcut=self._shortcuts.display(ShortcutId.TRACKER_SELECT_COLUMN),
+ callback=lambda: self._panel.select_shape(ShortcutId.TRACKER_SELECT_COLUMN, cell),
+ )
+ dpg.add_menu_item(
+ label=self._lbl_select_subcolumn,
+ shortcut=self._shortcuts.display(ShortcutId.TRACKER_SELECT_SUBCOLUMN),
+ callback=lambda: self._panel.select_shape(ShortcutId.TRACKER_SELECT_SUBCOLUMN, cell),
+ )
+
+ def _add_voice_submenu(self, cell: TrackerCursor) -> None:
+ """Offers the pool to a cell, each voice enabled where that cell's column takes it.
+
+ The whole pool is listed wherever the menu is raised, so a reader sees every voice the
+ project holds and where each one goes: a channel column takes any of them, while the
+ sample column spreads a voice over the channels it covers and so takes a recording alone.
+ A voice the column stands by for is offered unreachable, which says it exists while
+ leaving it where it belongs.
+ """
+ with dpg.menu(label=self._lbl_set_voice):
+ voices = self._panel.voices
+ if not voices:
+ dpg.add_menu_item(
+ label=self._lbl_no_voices,
+ enabled=False,
+ )
+ return
+
+ for index, voice in enumerate(voices):
+ dpg.add_menu_item(
+ label=tracker_display.indexed_label(index, voice.name),
+ user_data=(cell.row, cell.channel, voice.voice_id),
+ callback=self._on_set_voice_menu,
+ enabled=column_takes(cell.channel, voice.kind),
+ )
+
+ def _add_transpose_items(self, target: TrackerTarget) -> None:
+ self._add_adjust_items(target, TRANSPOSE_ACTIONS, self._on_transpose_menu)
+
+ def _add_volume_items(self, target: TrackerTarget) -> None:
+ self._add_adjust_items(target, VOLUME_ACTIONS, self._on_volume_menu)
+
+ def _add_adjust_items(
+ self,
+ target: TrackerTarget,
+ actions: Tuple[AdjustAction, ...],
+ callback: AdjustMenuCallback,
+ ) -> None:
+ """Builds one axis of adjustment items, each shifting the cells its target covers.
+
+ An adjustment acts on whole cells, so it reaches the columns the target's block covers and
+ the rows it spans: a nudge with a selection standing moves all of it, and one on a cell
+ alone moves that cell. Each item prints the key it answers to, since the action states its
+ label, its binding and its step in one entry.
+ """
+ for element, shortcut_id, delta in actions:
+ dpg.add_menu_item(
+ label=self._lbl_adjust[element],
+ shortcut=self._shortcuts.display(shortcut_id),
+ user_data=(target.region, delta),
+ callback=callback,
+ )
+
+ def _on_set_voice_menu(
+ self,
+ _sender: Sender,
+ _app_data: None,
+ user_data: Tuple[int, Optional[ChannelName], str],
+ ) -> None:
+ row_index, channel, voice_id = user_data
+ self.call(self._panel.on_set_row, row_index, channel, voice_id, None, None)
+
+ def _on_transpose_menu(
+ self,
+ _sender: Sender,
+ _app_data: None,
+ user_data: Tuple[TrackerRegion, int],
+ ) -> None:
+ region, delta = user_data
+ self.call(self._panel.on_adjust_transpose, region, delta)
+
+ def _on_volume_menu(
+ self,
+ _sender: Sender,
+ _app_data: None,
+ user_data: Tuple[TrackerRegion, int],
+ ) -> None:
+ region, delta = user_data
+ self.call(self._panel.on_adjust_volume, region, delta)
+
+ def _add_clear_items(self, cell: TrackerCursor) -> None:
+ """Builds the three clear levels: the target's subcolumn, its whole channel cell, its whole row.
+
+ The cell and row levels coincide on the sample column, which already clears every channel,
+ so the per-channel ``Clear cell`` item is offered only for an actual channel.
+ """
+ dpg.add_menu_item(
+ label=self._lbl_clear_subcolumn,
+ callback=lambda: self.call(
+ self._panel.on_clear_subcolumn,
+ cell.row,
+ cell.channel,
+ cell.subcolumn,
+ ),
+ )
+ if cell.channel is not None:
+ dpg.add_menu_item(
+ label=self._lbl_clear_cell,
+ callback=lambda: self.call(
+ self._panel.on_clear_row,
+ cell.row,
+ cell.channel,
+ ),
+ )
+ dpg.add_menu_item(
+ label=self._lbl_clear_row,
+ callback=lambda: self.call(self._panel.on_clear_row, cell.row, None),
+ )
diff --git a/src/sampletones_application/ui/panels/sequencer/tracker.py b/src/sampletones_application/ui/panels/sequencer/tracker/panel.py
similarity index 74%
rename from src/sampletones_application/ui/panels/sequencer/tracker.py
rename to src/sampletones_application/ui/panels/sequencer/tracker/panel.py
index 8427560d8..5f74bd79d 100644
--- a/src/sampletones_application/ui/panels/sequencer/tracker.py
+++ b/src/sampletones_application/ui/panels/sequencer/tracker/panel.py
@@ -7,6 +7,12 @@
)
from sampletones_application.categories.hierarchy import Page, Panel, TextType
from sampletones_application.categories.manager import LanguageManager
+from sampletones_application.constants.tracker import (
+ DEFAULT_OCTAVE,
+ MAX_OCTAVE,
+ MIN_OCTAVE,
+)
+from sampletones_application.layout.general.colors.channel import ChannelColors
from sampletones_application.layout.tabs.sequencer import SequencerLayout
from sampletones_application.tags.compose import compose_tag
from sampletones_application.tags.general import (
@@ -17,14 +23,11 @@
from sampletones_application.tags.sequencer import (
TAG_SEQUENCER_THEME_TABLE_PATTERN,
TAG_SEQUENCER_TRACKER_GROUP,
+ TAG_SEQUENCER_TRACKER_INPUT_OCTAVE,
TAG_SEQUENCER_TRACKER_PANEL,
TAG_SEQUENCER_TRACKER_TABLE,
TAG_SEQUENCER_TRACKER_WINDOW,
)
-from sampletones_application.ui.elements.context_menu import (
- add_play_menu_item,
- context_menu,
-)
from sampletones_application.ui.elements.fonts.font import Font
from sampletones_application.ui.elements.fonts.registry import FontRegistry
from sampletones_application.ui.elements.panel import GUIPanel
@@ -43,11 +46,14 @@
HEADER_TABLE_ROWS,
SAMPLE_TABLE_COLUMN,
TRACKER_TABLE_COLUMNS,
- channel_color,
tracker_table_column,
tracker_table_row,
)
-from sampletones_application.ui.panels.sequencer.display import CellKey, CellValues
+from sampletones_application.ui.panels.sequencer.display import (
+ CellKey,
+ CellKinds,
+ CellValues,
+)
from sampletones_application.ui.panels.sequencer.grid.gestures import BlockGestures
from sampletones_application.ui.panels.sequencer.grid.scroll.axis import VerticalScroll
from sampletones_application.ui.panels.sequencer.grid.scroll.band import TravelBand
@@ -56,19 +62,42 @@
BlockShortcuts,
ClipboardItems,
)
-from sampletones_application.ui.panels.sequencer.grid.surface.edit import GridEditSurface
+from sampletones_application.ui.panels.sequencer.grid.surface.edit import (
+ GridEditSurface,
+)
from sampletones_application.ui.panels.sequencer.input.edit import (
ClearAction,
EditAction,
)
from sampletones_application.ui.panels.sequencer.input.target import TrackerTarget
-from sampletones_application.ui.panels.sequencer.input.tracker import TrackerCursor, TrackerInputState
+from sampletones_application.ui.panels.sequencer.input.tracker import (
+ TrackerCursor,
+ TrackerInputState,
+)
from sampletones_application.ui.panels.sequencer.rows import RowCues, row_background
-from sampletones_application.ui.themes.inline import (
- create_header_selectable_theme,
- create_label_selectable_theme,
- create_selectable_text_theme,
+from sampletones_application.ui.panels.sequencer.tracker.adjust import (
+ TRANSPOSE_STEPS,
+ VOLUME_STEPS,
)
+from sampletones_application.ui.panels.sequencer.tracker.callbacks import (
+ CanPasteBlockQuery,
+ OnAdjustCallback,
+ OnBlockRegionCallback,
+ OnCellSelectedCallback,
+ OnChannelMuteToggledCallback,
+ OnChannelSoloedCallback,
+ OnClearRowCallback,
+ OnClearSubcolumnCallback,
+ OnNoteTypedCallback,
+ OnPasteBlockCallback,
+ OnPlayFromFrameCallback,
+ OnPlayFromRowCallback,
+ OnSetNoteOffCallback,
+ OnSetRowCallback,
+ TrackerEditSurface,
+)
+from sampletones_application.ui.panels.sequencer.tracker.menu import TrackerMenu
+from sampletones_application.ui.panels.sequencer.tracker.themes import TrackerThemes
from sampletones_application.ui.themes.registry import ThemeRegistry
from sampletones_application.utils.gui.dpg import dpg_delete_children
from sampletones_application.utils.gui.frame import FrameCallbackManager
@@ -79,7 +108,11 @@
KeyRouter,
)
from sampletones_application.utils.gui.keyboard.keys import HEX_KEYS, SIGN_KEYS
-from sampletones_application.utils.gui.keyboard.modifiers import Modifier, capture_modifiers
+from sampletones_application.utils.gui.keyboard.modifiers import (
+ Modifier,
+ capture_modifiers,
+)
+from sampletones_application.utils.gui.keyboard.piano import PIANO_KEYS
from sampletones_application.utils.gui.shortcuts.ids import ShortcutCategory, ShortcutId
from sampletones_application.utils.gui.shortcuts.source import ShortcutSource
from sampletones_application.utils.gui.tooltip import show_tooltip
@@ -89,9 +122,10 @@
from sampletones_application.view_model.sequencer.channels import (
SequencerChannelsViewModel,
)
-from sampletones_application.view_model.sequencer.region import TrackerCell, TrackerRegion
-from sampletones_application.view_model.sequencer.samples import (
- SequencerSamplesViewModel,
+from sampletones_application.view_model.sequencer.kind import column_takes
+from sampletones_application.view_model.sequencer.region import (
+ TrackerCell,
+ TrackerRegion,
)
from sampletones_application.view_model.sequencer.settings import (
SequencerSettingsViewModel,
@@ -106,91 +140,24 @@
SequencerRowViewModel,
SequencerTrackerViewModel,
)
-from sampletones_core.constants.enums import GeneratorName
-from sampletones_core.constants.general import MAX_VOLUME
+from sampletones_application.view_model.sequencer.voices import (
+ SequencerVoicesViewModel,
+ VoiceEntryViewModel,
+ VoiceKind,
+)
+from sampletones_core.constants.enums import ChannelName
+from sampletones_core.features import speaks_in_periods
from sampletones_core.project.song_position import SongPosition
from sampletones_core.utils.display import NOTE_OFF, display_id
-from sampletones_shared.constants.music import OCTAVE_SEMITONES, SEMITONE_STEP
+from sampletones_shared.constants.music import (
+ OCTAVE_OFFSET,
+ OCTAVE_SEMITONES,
+)
from sampletones_shared.types.application import ColorRGBA, Sender
from sampletones_shared.types.callback import VoidCallback
-OnClearRowCallback = Callable[[int, Optional[GeneratorName]], None]
-OnClearSubcolumnCallback = Callable[[int, Optional[GeneratorName], SubColumn], None]
-OnSetRowCallback = Callable[[int, Optional[GeneratorName], Optional[str], Optional[int], Optional[int]], None]
-OnSetNoteOffCallback = Callable[[int, Optional[GeneratorName]], None]
-OnCellSelectedCallback = VoidCallback
-OnPlayFromRowCallback = Callable[[int], None]
-OnPlayFromFrameCallback = VoidCallback
-OnAdjustCallback = Callable[[TrackerRegion, int], None]
-OnChannelMuteToggledCallback = Callable[[GeneratorName], None]
-OnChannelSoloedCallback = Callable[[GeneratorName], None]
-OnBlockRegionCallback = Callable[[TrackerRegion], None]
-OnPasteBlockCallback = Callable[[TrackerCell], None]
-CanPasteBlockQuery = Callable[[], bool]
-TrackerEditSurface = GridEditSurface[TrackerCursor, TrackerRegion, TrackerCell, TrackerTarget]
-
-
-VOLUME_FINE_STEP: Final[int] = 1
-VOLUME_COARSE_STEP: Final[int] = (MAX_VOLUME + 1) // 4
PLAYHEAD_PAINT_FRAMES: Final[int] = 1
-AdjustAction = Tuple[SequencerTrackerElements, ShortcutId, int]
-AdjustMenuCallback = Callable[[Sender, None, Tuple[TrackerRegion, int]], None]
-
-TRANSPOSE_ACTIONS: Final[Tuple[AdjustAction, ...]] = (
- (
- SequencerTrackerElements.CONTEXT_TRANSPOSE_UP,
- ShortcutId.TRACKER_TRANSPOSE_UP,
- SEMITONE_STEP,
- ),
- (
- SequencerTrackerElements.CONTEXT_TRANSPOSE_DOWN,
- ShortcutId.TRACKER_TRANSPOSE_DOWN,
- -SEMITONE_STEP,
- ),
- (
- SequencerTrackerElements.CONTEXT_TRANSPOSE_OCTAVE_UP,
- ShortcutId.TRACKER_TRANSPOSE_OCTAVE_UP,
- OCTAVE_SEMITONES,
- ),
- (
- SequencerTrackerElements.CONTEXT_TRANSPOSE_OCTAVE_DOWN,
- ShortcutId.TRACKER_TRANSPOSE_OCTAVE_DOWN,
- -OCTAVE_SEMITONES,
- ),
-)
-
-VOLUME_ACTIONS: Final[Tuple[AdjustAction, ...]] = (
- (
- SequencerTrackerElements.CONTEXT_VOLUME_UP,
- ShortcutId.TRACKER_VOLUME_UP,
- VOLUME_FINE_STEP,
- ),
- (
- SequencerTrackerElements.CONTEXT_VOLUME_DOWN,
- ShortcutId.TRACKER_VOLUME_DOWN,
- -VOLUME_FINE_STEP,
- ),
- (
- SequencerTrackerElements.CONTEXT_VOLUME_UP_COARSE,
- ShortcutId.TRACKER_VOLUME_UP_COARSE,
- VOLUME_COARSE_STEP,
- ),
- (
- SequencerTrackerElements.CONTEXT_VOLUME_DOWN_COARSE,
- ShortcutId.TRACKER_VOLUME_DOWN_COARSE,
- -VOLUME_COARSE_STEP,
- ),
-)
-
-
-def _steps(actions: Tuple[AdjustAction, ...]) -> Dict[ShortcutId, int]:
- return {shortcut_id: delta for _, shortcut_id, delta in actions}
-
-
-TRANSPOSE_STEPS: Final[Dict[ShortcutId, int]] = _steps(TRANSPOSE_ACTIONS)
-VOLUME_STEPS: Final[Dict[ShortcutId, int]] = _steps(VOLUME_ACTIONS)
-
class GUISequencerTrackerPanel(GUIPanel):
def __init__(
@@ -198,13 +165,17 @@ def __init__(
initial_settings: SequencerSettingsViewModel,
*,
layout: SequencerLayout,
+ channel_colors: ChannelColors,
language_manager: LanguageManager,
key_router: KeyRouter,
tab_active: ActivePredicate,
shortcut_source: ShortcutSource,
+ initial_octave: int = DEFAULT_OCTAVE,
initial_collapsed: bool = False,
) -> None:
self._layout = layout
+ self._channel_colors = channel_colors
+ self._octave = initial_octave
self._settings = initial_settings
self._language_manager = language_manager
self._router = key_router
@@ -213,7 +184,7 @@ def __init__(
widths = layout.tracker.subcolumn_widths
self._subcolumn_widths: Dict[SubColumn, int] = {
- SubColumn.INSTRUMENT: widths.instrument,
+ SubColumn.VOICE: widths.voice,
SubColumn.TRANSPOSE: widths.transpose,
SubColumn.VOLUME: widths.volume,
}
@@ -224,7 +195,7 @@ def __init__(
self._drag_handler_tag = compose_tag(TAG_SEQUENCER_TRACKER_TABLE, SUF_HANDLER_DRAG)
self._rows: Dict[Optional[int], Sender] = {}
- self._header_columns: Dict[Sender, Optional[GeneratorName]] = {}
+ self._header_columns: Dict[Sender, Optional[ChannelName]] = {}
self._editable_cells: EditableCells[CellKey] = EditableCells()
self._current_row_count: int = 0
self._highlighted_row: Optional[int] = None
@@ -244,19 +215,17 @@ def __init__(
band=self._travel_band,
elapsed=dpg.get_delta_time,
)
- self._subcolumn_themes: Dict[SubColumn, int] = {}
- self._muted_subcolumn_themes: Dict[SubColumn, int] = {}
- self._row_number_theme: int = 0
- self._header_theme: int = 0
- self._muted_header_theme: int = 0
- self._column_label_theme: int = 0
- self._current_samples: Optional[SequencerSamplesViewModel] = None
+ self._cell_kinds: CellKinds = {}
+ self._themes = TrackerThemes(layout)
+ self._current_samples: Optional[SequencerVoicesViewModel] = None
self._current_channels: Optional[SequencerChannelsViewModel] = None
self.on_clear_row: Optional[OnClearRowCallback] = None
self.on_clear_subcolumn: Optional[OnClearSubcolumnCallback] = None
self.on_set_row: Optional[OnSetRowCallback] = None
self.on_set_note_off: Optional[OnSetNoteOffCallback] = None
+ self.on_note_typed: Optional[OnNoteTypedCallback] = None
+ self.on_octave_changed: Optional[Callable[[int], None]] = None
self.on_cell_selected: Optional[OnCellSelectedCallback] = None
self.on_play_from_row: Optional[OnPlayFromRowCallback] = None
self.on_play_from_frame: Optional[OnPlayFromFrameCallback] = None
@@ -288,14 +257,28 @@ def __init__(
)
self.pattern_theme = ThemeRegistry.get(TAG_SEQUENCER_THEME_TABLE_PATTERN)
+ self._lbl_octave = self._label(
+ language_manager,
+ SequencerTrackerElements.OCTAVE,
+ )
+ self._tip_octave = language_manager[
+ Page.SEQUENCER,
+ Panel.TRACKER,
+ TextType.TOOLTIP,
+ SequencerTrackerElements.OCTAVE,
+ ]
self._lbl_tracker = self._label(
language_manager,
SequencerTrackerElements.TRACKER_TEXT,
)
self._load_column_labels(language_manager)
- self._load_context_labels(language_manager)
self._load_header_tooltips(language_manager)
self._create_channel_switch(language_manager)
+ self._menu = TrackerMenu(
+ self,
+ language_manager=language_manager,
+ shortcut_source=shortcut_source,
+ )
super().__init__(
tag=TAG_SEQUENCER_TRACKER_PANEL,
@@ -306,12 +289,12 @@ def __init__(
def _load_column_labels(self, language_manager: LanguageManager) -> None:
"""Reads the name each column carries, which its header label and its menu title show."""
self._lbl_col_row = self._label(language_manager, SequencerTrackerElements.COLUMN_ROW)
- self._column_labels: Dict[Optional[GeneratorName], str] = {
+ self._column_labels: Dict[Optional[ChannelName], str] = {
None: self._label(language_manager, SequencerTrackerElements.COLUMN_SAMPLE),
- GeneratorName.PULSE1: self._label(language_manager, SequencerTrackerElements.COLUMN_PULSE_1),
- GeneratorName.PULSE2: self._label(language_manager, SequencerTrackerElements.COLUMN_PULSE_2),
- GeneratorName.TRIANGLE: self._label(language_manager, SequencerTrackerElements.COLUMN_TRIANGLE),
- GeneratorName.NOISE: self._label(language_manager, SequencerTrackerElements.COLUMN_NOISE),
+ ChannelName.PULSE1: self._label(language_manager, SequencerTrackerElements.COLUMN_PULSE_1),
+ ChannelName.PULSE2: self._label(language_manager, SequencerTrackerElements.COLUMN_PULSE_2),
+ ChannelName.TRIANGLE: self._label(language_manager, SequencerTrackerElements.COLUMN_TRIANGLE),
+ ChannelName.NOISE: self._label(language_manager, SequencerTrackerElements.COLUMN_NOISE),
}
@staticmethod
@@ -326,25 +309,6 @@ def _label(
element,
]
- def _load_context_labels(self, language_manager: LanguageManager) -> None:
- def label(element: SequencerTrackerElements) -> str:
- return self._label(language_manager, element)
-
- self._lbl_context_play = label(SequencerTrackerElements.CONTEXT_PLAY)
- self._lbl_context_play_from_frame = label(SequencerTrackerElements.CONTEXT_PLAY_FROM_FRAME)
- self._lbl_context_select_all = label(SequencerTrackerElements.CONTEXT_SELECT_ALL)
- self._lbl_context_select_column = label(SequencerTrackerElements.CONTEXT_SELECT_COLUMN)
- self._lbl_context_select_subcolumn = label(SequencerTrackerElements.CONTEXT_SELECT_SUBCOLUMN)
- self._lbl_context_note_off = label(SequencerTrackerElements.CONTEXT_NOTE_OFF)
- self._lbl_context_set_instrument = label(SequencerTrackerElements.CONTEXT_SET_INSTRUMENT)
- self._lbl_context_no_samples = label(SequencerTrackerElements.CONTEXT_NO_SAMPLES)
- self._lbl_context_clear_subcolumn = label(SequencerTrackerElements.CONTEXT_CLEAR_SUBCOLUMN)
- self._lbl_context_clear_cell = label(SequencerTrackerElements.CONTEXT_CLEAR_CELL)
- self._lbl_context_clear_row = label(SequencerTrackerElements.CONTEXT_CLEAR_ROW)
- self._lbl_adjust: Dict[SequencerTrackerElements, str] = {
- element: label(element) for element, _, _ in (*TRANSPOSE_ACTIONS, *VOLUME_ACTIONS)
- }
-
def _load_header_tooltips(self, language_manager: LanguageManager) -> None:
"""Reads the header tooltips, which name the click gestures the labels carry."""
@@ -370,8 +334,8 @@ def _create_channel_switch(self, language_manager: LanguageManager) -> None:
)
self._channel_switch = ChannelSwitch(
labels=labels,
- on_mute_toggled=lambda generator: self.call(self.on_channel_mute_toggled, generator),
- on_soloed=lambda generator: self.call(self.on_channel_soloed, generator),
+ on_mute_toggled=lambda channel: self.call(self.on_channel_mute_toggled, channel),
+ on_soloed=lambda channel: self.call(self.on_channel_soloed, channel),
on_toggled=lambda: self.call(self.on_channels_toggled),
on_muted=lambda: self.call(self.on_channels_muted),
on_unmuted=lambda: self.call(self.on_channels_unmuted),
@@ -409,50 +373,30 @@ def _setup_handlers(self) -> None:
)
def _create_themes(self) -> None:
- self._create_subcolumn_themes()
- self._create_header_themes()
- self._row_number_theme = create_selectable_text_theme(self._layout.colors.text.row)
-
- def _create_subcolumn_themes(self) -> None:
- """Builds each subcolumn's text theme in its full and its dimmed colour.
-
- The dimmed variant keeps the subcolumn's own hue at reduced alpha, so a silenced
- channel's values stay readable and editable while the others are worked on.
- """
- subcolumn_colors = self._layout.colors.text
- theme_colors = {
- SubColumn.INSTRUMENT: subcolumn_colors.instrument,
- SubColumn.TRANSPOSE: subcolumn_colors.transpose,
- SubColumn.VOLUME: subcolumn_colors.volume,
- }
- fraction = self._layout.tracker.muted_text_fraction
- for subcolumn, color in theme_colors.items():
- self._subcolumn_themes[subcolumn] = create_selectable_text_theme(color)
- self._muted_subcolumn_themes[subcolumn] = create_selectable_text_theme(
- FadedColor(
- color=color,
- fraction=fraction,
- ),
+ self._themes.create()
+
+ def _create_octave_control(self) -> None:
+ """Offers the octave a note key types at, which is what turns one key row into a keyboard."""
+ with dpg.group(horizontal=True):
+ label = dpg.add_text(self._lbl_octave)
+ FontRegistry.bind_to_item(label, Font.REGULAR_SMALL)
+ octave_input = dpg.add_input_int(
+ tag=TAG_SEQUENCER_TRACKER_INPUT_OCTAVE,
+ default_value=self._octave,
+ min_value=MIN_OCTAVE,
+ max_value=MAX_OCTAVE,
+ min_clamped=True,
+ max_clamped=True,
+ width=self._layout.tracker.octave_width,
+ step=1,
+ callback=self._on_octave_typed,
)
+ FontRegistry.bind_to_item(octave_input, Font.MONO_SMALL)
+ show_tooltip(octave_input, self._tip_octave)
- def _create_header_themes(self) -> None:
- """Builds the two shades a channel's header label takes: audible and silenced.
-
- Both carry the header's own hover and press washes, so a label reads as the switch it is
- while its text colour reports whether the channel sounds.
- """
- header = self._layout.colors.header
- self._header_theme = create_header_selectable_theme(
- self._layout.colors.label,
- header.hovered,
- header.active,
- )
- self._muted_header_theme = create_header_selectable_theme(
- self._layout.colors.muted.text,
- header.hovered,
- header.active,
- )
- self._column_label_theme = create_label_selectable_theme(self._layout.colors.label)
+ def _on_octave_typed(self, _sender: Sender, app_data: int) -> None:
+ self._octave = max(MIN_OCTAVE, min(MAX_OCTAVE, app_data))
+ self.call(self.on_octave_changed, self._octave)
def _create_tracker_view(self, parent: str) -> None:
"""Builds the tracker card and the empty table its rows are filled into.
@@ -472,6 +416,7 @@ def _create_tracker_view(self, parent: str) -> None:
self._lbl_tracker,
glyph=self._glyphs.headers.tracker,
):
+ self._create_octave_control()
dpg.add_group(tag=TAG_SEQUENCER_TRACKER_GROUP)
with (
dpg.child_window(
@@ -513,10 +458,10 @@ def _create_tracker_view(self, parent: str) -> None:
width_fixed=True,
init_width_or_weight=self._layout.table_cells.divider,
)
- for _ in GeneratorName.items():
+ for _ in ChannelName.items():
dpg.add_table_column(
width_fixed=True,
- init_width_or_weight=self._layout.table_cells.generator,
+ init_width_or_weight=self._layout.table_cells.channel,
no_clip=True,
)
dpg.add_table_column(width_stretch=True)
@@ -532,10 +477,12 @@ def update_tracker(self, view_model: SequencerTrackerViewModel) -> None:
the edit cursor that a full rebuild would otherwise discard.
"""
cell_values = self._compute_cell_values(view_model)
+ cell_kinds = self._compute_cell_kinds(view_model)
self._show_frame(view_model.frame_index)
if len(view_model.rows) != self._current_row_count:
- self._rebuild_table(view_model, cell_values)
+ self._rebuild_table(view_model, cell_values, cell_kinds)
else:
+ self._reconcile_cell_kinds(cell_kinds)
self._editable_cells.reconcile(cell_values, self._render_cell)
def _show_frame(self, frame_index: int) -> None:
@@ -555,6 +502,7 @@ def _rebuild_table(
self,
view_model: SequencerTrackerViewModel,
cell_values: CellValues,
+ cell_kinds: CellKinds,
) -> None:
"""Replaces the table body, and re-reveals the sounding row once the new body has laid out.
@@ -569,6 +517,7 @@ def _rebuild_table(
self._selection.reset()
self._travel.rest()
self._editable_cells.reset(cell_values)
+ self._cell_kinds = dict(cell_kinds)
self._build_table(view_model)
self.repaint()
FrameCallbackManager.set_frame_callback(self._reveal_playing_row)
@@ -577,8 +526,8 @@ def repaint(self) -> None:
"""Issues every tint the table holds as its own state.
DearPyGui keeps a row, column or cell highlight on the table rather than on an item,
- so a colour reaches it only by being pushed again. Gathering the pushes here gives
- the palette one call to make and keeps a rebuilt table and a recoloured one identical.
+ so a color reaches it only by being pushed again. Gathering the pushes here gives
+ the palette one call to make and keeps a rebuilt table and a recolored one identical.
"""
if not dpg.does_item_exist(TAG_SEQUENCER_TRACKER_TABLE):
return
@@ -590,12 +539,12 @@ def repaint(self) -> None:
self._update_cursor()
def update_settings(self, view_model: SequencerSettingsViewModel) -> None:
- """Takes the metre the project states, retinting the rows its highlights now open."""
+ """Takes the meter the project states, retinting the rows its highlights now open."""
self._settings = view_model
self._apply_row_backgrounds()
def _row_background(self, row_index: int) -> Optional[BaseColor]:
- """The colour a pattern row's background carries under the marks standing on it now."""
+ """The color a pattern row's background carries under the marks standing on it now."""
cursor = self._input_state.cursor
return row_background(
row_index,
@@ -612,7 +561,7 @@ def _draw_row(
row_index: int,
color: Optional[BaseColor],
) -> None:
- """Gives one pattern row the background colour it resolved to.
+ """Gives one pattern row the background color it resolved to.
Position updates arrive on the callback-queue worker thread, so the table may be shorter
than the row asked for if the main thread shrank it (a rows-per-pattern change) in between;
@@ -635,7 +584,7 @@ def _draw_row(
)
def _paint_row(self, row_index: int) -> None:
- """Draws a row in the colour its group and the marks on it resolve to."""
+ """Draws a row in the color its group and the marks on it resolve to."""
self._draw_row(row_index, self._row_background(row_index))
def _paint_hovered_row(self, row_index: int) -> None:
@@ -653,10 +602,10 @@ def _apply_row_backgrounds(self) -> None:
self._paint_row(row_index)
def _render_cell(self, key: CellKey) -> str:
- row, generator, subcolumn = key
+ row, channel, subcolumn = key
return tracker_display.subcolumn_label(
row,
- generator,
+ channel,
subcolumn,
cursor=self._input_state.cursor,
pending=self._input_state.pending,
@@ -697,27 +646,27 @@ def _highlight_header_row(self) -> None:
)
def _tint_channel_columns(self) -> None:
- """Washes each channel's column with a faint tint of its identity colour.
+ """Washes each channel's column with a faint tint of its identity color.
Reapplied after each rebuild alongside the sample column so the tint survives
row replacement, giving the tracker the same per-channel identity the order
table carries in its row labels. A silenced channel trades that identity for a
neutral dark shade, so its column recedes as a whole.
"""
- for generator in GeneratorName.items():
+ for channel in ChannelName.items():
dpg.highlight_table_column(
TAG_SEQUENCER_TRACKER_TABLE,
- tracker_table_column(generator),
- self._channel_column_tint(generator),
+ tracker_table_column(channel),
+ self._channel_column_tint(channel),
)
- def _channel_column_tint(self, generator: GeneratorName) -> ColorRGBA:
- if self._is_muted(generator):
+ def _channel_column_tint(self, channel: ChannelName) -> ColorRGBA:
+ if self._is_muted(channel):
return self._layout.colors.muted.background.rgba
- channel = channel_color(self._layout.colors.channels, generator)
+ tint_color = self._channel_colors.for_channel(channel)
return FadedColor(
- color=channel,
+ color=tint_color,
fraction=self._layout.tracker.channel_column_tint,
).rgba
@@ -727,16 +676,16 @@ def _compute_cell_values(
) -> CellValues:
cell_values: CellValues = {}
for row in view_model.rows:
- cell_values[(row.index, None, SubColumn.INSTRUMENT)] = row.sample_instrument
- cell_values[(row.index, None, SubColumn.TRANSPOSE)] = row.sample_transpose
- cell_values[(row.index, None, SubColumn.VOLUME)] = row.sample_volume
- for generator in GeneratorName.items():
- cell = row.cells[generator]
+ cell_values[(row.index, None, SubColumn.VOICE)] = row.sample
+ cell_values[(row.index, None, SubColumn.TRANSPOSE)] = row.transpose
+ cell_values[(row.index, None, SubColumn.VOLUME)] = row.volume
+ for channel in ChannelName.items():
+ cell = row.cells[channel]
for subcolumn in SubColumn:
cell_values[
(
row.index,
- generator,
+ channel,
subcolumn,
)
] = tracker_display.cell_display(
@@ -746,6 +695,36 @@ def _compute_cell_values(
return cell_values
+ def _compute_cell_kinds(
+ self,
+ view_model: SequencerTrackerViewModel,
+ ) -> CellKinds:
+ """Which kind of voice each voice slot names, which is the color that slot wears.
+
+ Only the voice slot reports a kind, so the map covers those cells alone and a refresh
+ re-themes as many of them as the edit touched.
+ """
+ cell_kinds: CellKinds = {}
+ for row in view_model.rows:
+ cell_kinds[(row.index, None, SubColumn.VOICE)] = row.sample_kind
+ for channel in ChannelName.items():
+ cell_kinds[(row.index, channel, SubColumn.VOICE)] = row.cells[channel].kind
+
+ return cell_kinds
+
+ def _reconcile_cell_kinds(self, cell_kinds: CellKinds) -> None:
+ """Re-themes the voice slots whose kind changed, leaving the rest of the grid bound.
+
+ The labels are diffed the same way, so a refresh costs what the edit did rather than what
+ the grid holds.
+ """
+ for key, kind in cell_kinds.items():
+ if self._cell_kinds.get(key) == kind:
+ continue
+
+ self._cell_kinds[key] = kind
+ self._bind_cell_theme(key)
+
def _build_table(self, view_model: SequencerTrackerViewModel) -> None:
self._rows = {}
self._current_row_count = len(view_model.rows)
@@ -766,8 +745,8 @@ def _build_header_row(self) -> None:
self._add_header_label_cell(row_id)
self._add_header_selectable(row_id, None)
self._add_empty_cell(row_id)
- for generator in GeneratorName.items():
- self._add_header_selectable(row_id, generator)
+ for channel in ChannelName.items():
+ self._add_header_selectable(row_id, channel)
def _add_header_label_cell(self, row_id: Sender) -> None:
"""Places the row-number column's label, which names a column the user reads only.
@@ -781,12 +760,12 @@ def _add_header_label_cell(self, row_id: Sender) -> None:
label=self._lbl_col_row,
height=self._layout.tracker.header_height,
)
- dpg.bind_item_theme(label, self._column_label_theme)
+ dpg.bind_item_theme(label, self._themes.column_label)
def _add_header_selectable(
self,
row_id: Sender,
- generator: Optional[GeneratorName],
+ channel: Optional[ChannelName],
) -> None:
"""Places one clickable column label: a channel's mute target, or the master target.
@@ -798,17 +777,17 @@ def _add_header_selectable(
header_cell = dpg.add_table_cell(parent=row_id)
selectable = dpg.add_selectable(
parent=header_cell,
- label=self._column_labels[generator],
+ label=self._column_labels[channel],
height=self._layout.tracker.header_height,
- user_data=generator,
+ user_data=channel,
callback=self._on_header_clicked,
)
dpg.bind_item_handler_registry(selectable, self._header_handler_tag)
show_tooltip(
selectable,
- self._tooltip_header_sample if generator is None else self._tooltip_header_channel,
+ self._tooltip_header_sample if channel is None else self._tooltip_header_channel,
)
- self._header_columns[selectable] = generator
+ self._header_columns[selectable] = channel
def _build_table_row(self, row: SequencerRowViewModel) -> None:
"""Builds one tracker row.
@@ -824,8 +803,8 @@ def _build_table_row(self, row: SequencerRowViewModel) -> None:
self._add_row_number_cell(row_id, row.index)
self._add_column_cell(row_id, row.index, None)
self._add_empty_cell(row_id)
- for generator in GeneratorName.items():
- self._add_column_cell(row_id, row.index, generator)
+ for channel in ChannelName.items():
+ self._add_column_cell(row_id, row.index, channel)
def _add_empty_cell(self, row_id: Sender) -> None:
empty_cell = dpg.add_table_cell(parent=row_id)
@@ -842,7 +821,7 @@ def _add_row_number_cell(self, row_id: Sender, row_index: int) -> None:
callback=self._on_row_number_clicked,
)
FontRegistry.bind_to_item(selectable, Font.MONO_SMALL)
- dpg.bind_item_theme(selectable, self._row_number_theme)
+ dpg.bind_item_theme(selectable, self._themes.row_number)
dpg.bind_item_handler_registry(selectable, self._item_handler_tag)
self._rows[row_index] = selectable
@@ -850,9 +829,9 @@ def _add_column_cell(
self,
row_id: Sender,
row_index: int,
- generator: Optional[GeneratorName],
+ channel: Optional[ChannelName],
) -> None:
- font = Font.MONO_BOLD_SMALL if generator is None else Font.MONO_SMALL
+ font = Font.MONO_BOLD_SMALL if channel is None else Font.MONO_SMALL
cell = dpg.add_table_cell(parent=row_id)
group = dpg.add_group(
horizontal=True,
@@ -863,7 +842,7 @@ def _add_column_cell(
self._add_subcolumn_selectable(
group,
row_index,
- generator,
+ channel,
subcolumn,
font,
)
@@ -872,11 +851,11 @@ def _add_subcolumn_selectable(
self,
group: Sender,
row_index: int,
- generator: Optional[GeneratorName],
+ channel: Optional[ChannelName],
subcolumn: SubColumn,
font: Font,
) -> None:
- key = (row_index, generator, subcolumn)
+ key = (row_index, channel, subcolumn)
selectable = dpg.add_selectable(
parent=group,
label=self._render_cell(key),
@@ -886,7 +865,7 @@ def _add_subcolumn_selectable(
callback=self._on_cell_clicked,
)
FontRegistry.bind_to_item(selectable, font)
- dpg.bind_item_theme(selectable, self._subcolumn_themes[subcolumn])
+ dpg.bind_item_theme(selectable, self._cell_theme(key))
dpg.bind_item_handler_registry(selectable, self._cell_handler_tag)
self._editable_cells.register(key, selectable)
@@ -894,7 +873,7 @@ def _update_cursor(self) -> None:
cursor = self._input_state.cursor
if cursor is not None:
if cursor.row < self._current_row_count:
- self._apply_cell_highlight(cursor.row, cursor.generator)
+ self._apply_cell_highlight(cursor.row, cursor.channel)
else:
self._input_state = TrackerInputState()
@@ -905,7 +884,7 @@ def deselect_cell(self) -> None:
cursor = self._input_state.cursor
if cursor is not None:
self._input_state = TrackerInputState()
- self._remove_cell_highlight(cursor.row, cursor.generator)
+ self._remove_cell_highlight(cursor.row, cursor.channel)
self._selection.repaint()
self._update_caret()
@@ -914,21 +893,21 @@ def _apply_state(self, new_state: TrackerInputState) -> None:
old_cursor = self._input_state.cursor
new_cursor = new_state.cursor
- old_pos = (old_cursor.row, old_cursor.generator) if old_cursor is not None else None
- new_pos = (new_cursor.row, new_cursor.generator) if new_cursor is not None else None
+ old_pos = (old_cursor.row, old_cursor.channel) if old_cursor is not None else None
+ new_pos = (new_cursor.row, new_cursor.channel) if new_cursor is not None else None
self._input_state = new_state
if old_pos != new_pos and old_cursor is not None:
- self._remove_cell_highlight(old_cursor.row, old_cursor.generator)
+ self._remove_cell_highlight(old_cursor.row, old_cursor.channel)
if old_cursor is not None:
- self._update_cell_display(old_cursor.row, old_cursor.generator)
+ self._update_cell_display(old_cursor.row, old_cursor.channel)
if new_cursor is not None:
if old_pos != new_pos:
- self._apply_cell_highlight(new_cursor.row, new_cursor.generator)
- self._update_cell_display(new_cursor.row, new_cursor.generator)
+ self._apply_cell_highlight(new_cursor.row, new_cursor.channel)
+ self._update_cell_display(new_cursor.row, new_cursor.channel)
if new_pos != old_pos and new_cursor is not None:
self.call(self.on_cell_selected)
@@ -936,7 +915,7 @@ def _apply_state(self, new_state: TrackerInputState) -> None:
self._selection.repaint()
self._update_caret()
- def update_samples(self, view_model: SequencerSamplesViewModel) -> None:
+ def update_samples(self, view_model: SequencerVoicesViewModel) -> None:
self._current_samples = view_model
def update_channels(self, view_model: SequencerChannelsViewModel) -> None:
@@ -960,27 +939,45 @@ def _apply_channel_cues(self) -> None:
self._tint_channel_columns()
self._bind_header_themes()
- for generator in GeneratorName.items():
- self._bind_channel_cell_themes(generator)
+ for channel in ChannelName.items():
+ self._bind_channel_cell_themes(channel)
def _bind_header_themes(self) -> None:
- for selectable, generator in self._header_columns.items():
- muted = generator is not None and self._is_muted(generator)
- dpg.bind_item_theme(
- selectable,
- self._muted_header_theme if muted else self._header_theme,
- )
+ for selectable, channel in self._header_columns.items():
+ muted = channel is not None and self._is_muted(channel)
+ dpg.bind_item_theme(selectable, self._themes.header(muted=muted))
- def _bind_channel_cell_themes(self, generator: GeneratorName) -> None:
- themes = self._muted_subcolumn_themes if self._is_muted(generator) else self._subcolumn_themes
+ def _bind_channel_cell_themes(self, channel: ChannelName) -> None:
for row_index in range(self._current_row_count):
for subcolumn in SubColumn:
- cell_id = self._editable_cells.widget((row_index, generator, subcolumn))
- if cell_id is not None:
- dpg.bind_item_theme(cell_id, themes[subcolumn])
+ self._bind_cell_theme((row_index, channel, subcolumn))
+
+ def _bind_cell_theme(self, key: CellKey) -> None:
+ """Gives one cell the theme it currently answers to, where the grid holds that cell."""
+ cell_id = self._editable_cells.widget(key)
+ if cell_id is not None:
+ dpg.bind_item_theme(cell_id, self._cell_theme(key))
+
+ def _cell_theme(self, key: CellKey) -> int:
+ """The theme a cell wears: its slot's color, dimmed while its channel is silenced.
+
+ A voice slot takes the color of the kind of voice standing in it, so a reader tells a
+ recording from a hand-written one across the whole grid; a slot naming none takes the
+ neutral shade the other slots' colors are read against.
+ """
+ _, channel, subcolumn = key
+ return self._themes.cell(
+ subcolumn,
+ self._cell_kinds.get(key),
+ muted=self._is_muted_cell(channel),
+ )
+
+ def _is_muted_cell(self, channel: Optional[ChannelName]) -> bool:
+ """Whether a cell's channel is silenced, the sample column speaking for every channel."""
+ return channel is not None and self._is_muted(channel)
- def _is_muted(self, generator: GeneratorName) -> bool:
- return self._current_channels is not None and self._current_channels.is_muted(generator)
+ def _is_muted(self, channel: ChannelName) -> bool:
+ return self._current_channels is not None and self._current_channels.is_muted(channel)
def set_enabled(self, enabled: bool) -> None:
dpg.configure_item(TAG_SEQUENCER_TRACKER_GROUP, enabled=enabled)
@@ -988,10 +985,10 @@ def set_enabled(self, enabled: bool) -> None:
def _update_cell_display(
self,
row: int,
- generator: Optional[GeneratorName],
+ channel: Optional[ChannelName],
) -> None:
for subcolumn in SubColumn:
- key = (row, generator, subcolumn)
+ key = (row, channel, subcolumn)
cell_id = self._editable_cells.widget(key)
if cell_id is not None:
dpg.configure_item(cell_id, label=self._render_cell(key))
@@ -1003,8 +1000,8 @@ def _update_caret(self) -> None:
CaretOverlay.clear(TAG_SEQUENCER_TRACKER_TABLE)
return
- key = (cursor.row, cursor.generator, cursor.subcolumn)
- font = Font.MONO_BOLD_SMALL if cursor.generator is None else Font.MONO_SMALL
+ key = (cursor.row, cursor.channel, cursor.subcolumn)
+ font = Font.MONO_BOLD_SMALL if cursor.channel is None else Font.MONO_SMALL
CaretOverlay.set_target(
owner=TAG_SEQUENCER_TRACKER_TABLE,
widget=self._editable_cells.widget(key),
@@ -1013,16 +1010,28 @@ def _update_caret(self) -> None:
clip_widget=TAG_SEQUENCER_TRACKER_WINDOW,
)
- def _resolve_sample_id(
+ def _resolve_voice(
self,
sample_index: int,
- ) -> Optional[Tuple[int, str]]:
- if not self._current_samples or not self._current_samples.samples:
+ channel: Optional[ChannelName],
+ ) -> Optional[Tuple[int, VoiceEntryViewModel]]:
+ """The voice a typed number names, where the column it was typed in takes that voice.
+
+ A number past the end of the pool reads as the last voice, so a reader typing freely lands
+ on something. The sample column stands by for a voice it cannot spread over channels, and
+ answering nothing here is what leaves the cell showing the value it already held. The whole
+ entry comes back, so the cell takes the voice's kind along with its number.
+ """
+ if not self._current_samples or not self._current_samples.voices:
+ return None
+
+ voices = self._current_samples.voices
+ sample_index = max(0, min(sample_index, len(voices) - 1))
+ voice = voices[sample_index]
+ if not column_takes(channel, voice.kind):
return None
- samples = self._current_samples.samples
- sample_index = max(0, min(sample_index, len(samples) - 1))
- return sample_index, samples[sample_index].sample_id
+ return sample_index, voice
def _handle_edit_action(self, action: EditAction) -> None:
"""Commits a single-subcolumn edit.
@@ -1031,32 +1040,35 @@ def _handle_edit_action(self, action: EditAction) -> None:
the others are ``None`` meaning "leave unchanged". Forwarding those ``None``
values lets the downstream partial update preserve the rest of the row.
"""
- row, generator = action.row, action.generator
+ row, channel = action.row, action.channel
if action.note_off:
- self._editable_cells.values[(row, generator, SubColumn.INSTRUMENT)] = NOTE_OFF
- self.call(self.on_set_note_off, row, generator)
+ self._show_voice(row, channel, NOTE_OFF, None)
+ self.call(self.on_set_note_off, row, channel)
return
- sample_id: Optional[str] = None
+ voice_id: Optional[str] = None
if action.sample_index is not None:
- resolved = self._resolve_sample_id(action.sample_index)
- sample_index = resolved[0] if resolved is not None else None
- sample_id = resolved[1] if resolved is not None else None
- self._editable_cells.values[(row, generator, SubColumn.INSTRUMENT)] = tracker_display.format_committed(
- SubColumn.INSTRUMENT,
- sample_index,
- )
+ resolved = self._resolve_voice(action.sample_index, channel)
+ if resolved is not None:
+ sample_index, voice = resolved
+ voice_id = voice.voice_id
+ self._show_voice(
+ row,
+ channel,
+ tracker_display.format_committed(SubColumn.VOICE, sample_index),
+ voice.kind,
+ )
if action.transpose is not None:
- self._editable_cells.values[(row, generator, SubColumn.TRANSPOSE)] = tracker_display.format_committed(
+ self._editable_cells.values[(row, channel, SubColumn.TRANSPOSE)] = tracker_display.format_committed(
SubColumn.TRANSPOSE,
action.transpose,
)
if action.volume is not None:
- self._editable_cells.values[(row, generator, SubColumn.VOLUME)] = tracker_display.format_committed(
+ self._editable_cells.values[(row, channel, SubColumn.VOLUME)] = tracker_display.format_committed(
SubColumn.VOLUME,
action.volume,
)
@@ -1064,43 +1076,72 @@ def _handle_edit_action(self, action: EditAction) -> None:
self.call(
self.on_set_row,
row,
- generator,
- sample_id,
+ channel,
+ voice_id,
action.transpose,
action.volume,
)
+ def _show_voice(
+ self,
+ row: int,
+ channel: Optional[ChannelName],
+ label: str,
+ kind: Optional[VoiceKind],
+ ) -> None:
+ """Shows a voice slot's new reading before the project answers, color and number together.
+
+ The panel holds both caches, so an edit that the logic goes on to refuse leaves the cell
+ reading exactly what it held.
+ """
+ key = (row, channel, SubColumn.VOICE)
+ self._editable_cells.values[key] = label
+ self._cell_kinds[key] = kind
+ self._bind_cell_theme(key)
+
+ def _forget_voice(self, row: int, channel: Optional[ChannelName]) -> None:
+ """Empties a voice slot's caches, so a cleared cell drops its number and its color as one."""
+ key = (row, channel, SubColumn.VOICE)
+ self._editable_cells.values.pop(key, None)
+ self._cell_kinds[key] = None
+ self._bind_cell_theme(key)
+
def _handle_clear_action(self, action: ClearAction) -> None:
if action.subcolumn is None:
for subcolumn in SubColumn:
self._editable_cells.values.pop(
- (action.row, action.generator, subcolumn),
+ (action.row, action.channel, subcolumn),
None,
)
- self.call(self.on_clear_row, action.row, action.generator)
+ self._forget_voice(action.row, action.channel)
+ self.call(self.on_clear_row, action.row, action.channel)
else:
- self._editable_cells.values.pop(
- (action.row, action.generator, action.subcolumn),
- None,
- )
+ if action.subcolumn is SubColumn.VOICE:
+ self._forget_voice(action.row, action.channel)
+ else:
+ self._editable_cells.values.pop(
+ (action.row, action.channel, action.subcolumn),
+ None,
+ )
+
self.call(
self.on_clear_subcolumn,
action.row,
- action.generator,
+ action.channel,
action.subcolumn,
)
def _apply_cell_highlight(
self,
row_index: int,
- generator: Optional[GeneratorName],
+ channel: Optional[ChannelName],
) -> None:
"""Marks the cursor: its cell on the cell layer, its row through the row background."""
self._paint_row(row_index)
dpg.highlight_table_cell(
TAG_SEQUENCER_TRACKER_TABLE,
tracker_table_row(row_index),
- tracker_table_column(generator),
+ tracker_table_column(channel),
color=self._layout.colors.cell_cursor.rgba,
)
@@ -1120,14 +1161,14 @@ def _selected_cells(self) -> FrozenSet[CellKey]:
continue
for slot in region.slots:
- keys.add((row_index, slot.generator, slot.subcolumn))
+ keys.add((row_index, slot.channel, slot.subcolumn))
return frozenset(keys)
def _remove_cell_highlight(
self,
row_index: int,
- generator: Optional[GeneratorName],
+ channel: Optional[ChannelName],
) -> None:
"""Clears the cursor cell and returns its row to the background the row itself carries.
@@ -1137,7 +1178,7 @@ def _remove_cell_highlight(
dpg.unhighlight_table_cell(
TAG_SEQUENCER_TRACKER_TABLE,
tracker_table_row(row_index),
- tracker_table_column(generator),
+ tracker_table_column(channel),
)
self._paint_row(row_index)
@@ -1145,7 +1186,7 @@ def _on_cell_clicked(
self,
sender: Sender,
_app_data: bool,
- user_data: Tuple[int, Optional[GeneratorName], SubColumn],
+ user_data: Tuple[int, Optional[ChannelName], SubColumn],
) -> None:
"""Places the cursor on the clicked cell, or carries a selection out to it while Shift is held.
@@ -1156,14 +1197,15 @@ def _on_cell_clicked(
return
state = self._committed_state()
- row_index, generator, subcolumn = user_data
- cursor = TrackerCursor(row_index, generator, subcolumn)
+ row_index, channel, subcolumn = user_data
+ cursor = TrackerCursor(row_index, channel, subcolumn)
if Modifier.SHIFT in capture_modifiers():
self._apply_state(state.extend_to(cursor))
return
self._apply_state(TrackerInputState(cursor=cursor, pending=""))
+ # TODO: to abstract [_on_cell_held]
def _on_cell_held(self, _sender: Sender, app_data: Sender) -> None:
"""Carries the selection to the cell under a held pointer, which is what drags a range out.
@@ -1218,7 +1260,7 @@ def _cell_at(self) -> Optional[CellKey]:
if row_index is None or slot is None:
return None
- return (row_index, slot.generator, slot.subcolumn)
+ return (row_index, slot.channel, slot.subcolumn)
def _row_at(self, top: float) -> Optional[int]:
"""Which pattern row stands at a height, counted from the first row's top edge.
@@ -1244,7 +1286,7 @@ def _slot_at(self, left: float) -> Optional[TrackerSlot]:
for index in range(SLOT_COUNT):
slot = slot_from_flat(index)
- widget = self._editable_cells.widget((0, slot.generator, slot.subcolumn))
+ widget = self._editable_cells.widget((0, slot.channel, slot.subcolumn))
if widget is None:
return None
@@ -1259,7 +1301,7 @@ def _on_header_clicked(
self,
sender: Sender,
_app_data: bool,
- user_data: Optional[GeneratorName],
+ user_data: Optional[ChannelName],
) -> None:
self._channel_switch.click(sender, user_data)
@@ -1280,18 +1322,7 @@ def _on_header_right_clicked(
if clicked_item not in self._header_columns:
return
- self._show_header_context_menu(self._header_columns[clicked_item])
-
- def _show_header_context_menu(
- self,
- generator: Optional[GeneratorName],
- ) -> None:
- """Opens the menu behind a column header, titled with the column's own name."""
- with context_menu():
- header = dpg.add_text(self._column_labels[generator])
- FontRegistry.bind_to_item(header, Font.MONO_BOLD)
- dpg.add_separator()
- self._channel_switch.add_menu_items(generator, self._current_channels)
+ self._menu.show_for_header(self._header_columns[clicked_item])
def _on_cell_right_clicked(
self,
@@ -1311,193 +1342,47 @@ def _on_cell_right_clicked(
if key is None:
return
- row_index, generator, subcolumn = key
- self._show_context_menu(row_index, generator, subcolumn)
-
- def _show_context_menu(
- self,
- row_index: int,
- generator: Optional[GeneratorName],
- subcolumn: SubColumn,
- ) -> None:
- target = self._surface.target_at(TrackerCursor(row_index, generator, subcolumn))
- with context_menu():
- header = dpg.add_text(
- tracker_display.cell_title(row_index, self._column_labels[generator]),
- )
- FontRegistry.bind_to_item(header, Font.MONO_BOLD)
- dpg.add_separator()
- add_play_menu_item(
- self._lbl_context_play,
- lambda: self.call(self.on_play_from_row, row_index),
- shortcut=self._shortcuts.display(ShortcutId.TRACKER_PLAY_FROM_ROW),
- )
- add_play_menu_item(
- self._lbl_context_play_from_frame,
- lambda: self.call(self.on_play_from_frame),
- shortcut=self._shortcuts.display(ShortcutId.PLAY_FROM_FRAME),
- )
- dpg.add_separator()
- self.add_action_items(target)
+ row_index, channel, subcolumn = key
+ self._menu.show_for_cell(row_index, channel, subcolumn)
+ # TODO: to abstract
@property
def edit_surface(self) -> TrackerEditSurface:
"""This grid as the menu bar's Edit menu reaches it."""
return self._surface
+ # TODO: to abstract
def input_state(self) -> TrackerInputState:
"""Where the cursor stands and what it has selected, which a target is resolved from."""
return self._input_state
+ # TODO: to abstract
def owns_keys(self) -> bool:
"""Whether the grid owns the next key, which is also what the Edit menu asks."""
return self._keys_active()
- def add_action_items(self, target: TrackerTarget) -> None:
- """Builds every action a tracker cell offers, in the order each menu prints them.
-
- The grid states its actions once, and whoever asks for them decides where they are shown:
- the cell menu asks for the cell a pointer landed on, and the menu bar asks for the cell the
- cursor stands on. An action added here reaches both.
- """
- self._add_select_items(target.cell)
- dpg.add_separator()
- self._surface.add_block_items(target)
- dpg.add_separator()
- self._add_instrument_submenu(target.cell)
- dpg.add_menu_item(
- label=self._lbl_context_note_off,
- callback=lambda: self.call(self.on_set_note_off, target.cell.row, target.cell.generator),
- )
- dpg.add_separator()
- self._add_transpose_items(target)
- dpg.add_separator()
- self._add_volume_items(target)
- dpg.add_separator()
- self._add_clear_items(target.cell)
-
- def _add_select_items(self, cell: TrackerCursor) -> None:
- """Builds the three shapes a selection takes, from the whole frame down to one subcolumn.
-
- Each item fires the gesture its key fires, on the cell the menu names: a column selected
- from a cell menu is the column that cell stands in, and one selected from the menu bar is
- the column the cursor stands in.
- """
- dpg.add_menu_item(
- label=self._lbl_context_select_all,
- shortcut=self._shortcuts.display(ShortcutId.TRACKER_SELECT_ALL),
- callback=lambda: self._select_shape(ShortcutId.TRACKER_SELECT_ALL, cell),
- )
- dpg.add_menu_item(
- label=self._lbl_context_select_column,
- shortcut=self._shortcuts.display(ShortcutId.TRACKER_SELECT_COLUMN),
- callback=lambda: self._select_shape(ShortcutId.TRACKER_SELECT_COLUMN, cell),
- )
- dpg.add_menu_item(
- label=self._lbl_context_select_subcolumn,
- shortcut=self._shortcuts.display(ShortcutId.TRACKER_SELECT_SUBCOLUMN),
- callback=lambda: self._select_shape(ShortcutId.TRACKER_SELECT_SUBCOLUMN, cell),
- )
-
- def _add_instrument_submenu(self, cell: TrackerCursor) -> None:
- with dpg.menu(label=self._lbl_context_set_instrument):
- samples = self._current_samples.samples if self._current_samples is not None else ()
- if not samples:
- dpg.add_menu_item(
- label=self._lbl_context_no_samples,
- enabled=False,
- )
- return
-
- for index, sample in enumerate(samples):
- dpg.add_menu_item(
- label=tracker_display.indexed_label(index, sample.name),
- user_data=(cell.row, cell.generator, sample.sample_id),
- callback=self._on_set_instrument_menu,
- )
-
- def _add_transpose_items(self, target: TrackerTarget) -> None:
- self._add_adjust_items(target, TRANSPOSE_ACTIONS, self._on_transpose_menu)
-
- def _add_volume_items(self, target: TrackerTarget) -> None:
- self._add_adjust_items(target, VOLUME_ACTIONS, self._on_volume_menu)
-
- def _add_adjust_items(
- self,
- target: TrackerTarget,
- actions: Tuple[AdjustAction, ...],
- callback: AdjustMenuCallback,
- ) -> None:
- """Builds one axis of adjustment items, each shifting the cells its target covers.
-
- An adjustment acts on whole cells, so it reaches the columns the target's block covers and
- the rows it spans: a nudge with a selection standing moves all of it, and one on a cell
- alone moves that cell. Each item prints the key it answers to, since the action states its
- label, its binding and its step in one entry.
- """
- for element, shortcut_id, delta in actions:
- dpg.add_menu_item(
- label=self._lbl_adjust[element],
- shortcut=self._shortcuts.display(shortcut_id),
- user_data=(target.region, delta),
- callback=callback,
- )
-
- def _on_set_instrument_menu(
- self,
- _sender: Sender,
- _app_data: None,
- user_data: Tuple[int, Optional[GeneratorName], str],
- ) -> None:
- row_index, generator, sample_id = user_data
- self.call(self.on_set_row, row_index, generator, sample_id, None, None)
+ @property
+ def channel_switch(self) -> ChannelSwitch:
+ """The switch a column header's click and menu act through."""
+ return self._channel_switch
- def _on_transpose_menu(
- self,
- _sender: Sender,
- _app_data: None,
- user_data: Tuple[TrackerRegion, int],
- ) -> None:
- region, delta = user_data
- self.call(self.on_adjust_transpose, region, delta)
+ @property
+ def channels(self) -> Optional[SequencerChannelsViewModel]:
+ """Which channels stand silenced, as the last view the panel was given states it."""
+ return self._current_channels
- def _on_volume_menu(
- self,
- _sender: Sender,
- _app_data: None,
- user_data: Tuple[TrackerRegion, int],
- ) -> None:
- region, delta = user_data
- self.call(self.on_adjust_volume, region, delta)
+ @property
+ def voices(self) -> Tuple[VoiceEntryViewModel, ...]:
+ """The pool a cell picks a voice from, empty while the panel has been given none."""
+ return self._current_samples.voices if self._current_samples is not None else ()
- def _add_clear_items(self, cell: TrackerCursor) -> None:
- """Builds the three clear levels: the target's subcolumn, its whole channel cell, its whole row.
+ def column_label(self, channel: Optional[ChannelName]) -> str:
+ """The name a column carries, which its header and its menu title show."""
+ return self._column_labels[channel]
- The cell and row levels coincide on the sample column, which already clears every channel,
- so the per-channel ``Clear cell`` item is offered only for an actual channel.
- """
- dpg.add_menu_item(
- label=self._lbl_context_clear_subcolumn,
- callback=lambda: self.call(
- self.on_clear_subcolumn,
- cell.row,
- cell.generator,
- cell.subcolumn,
- ),
- )
- if cell.generator is not None:
- dpg.add_menu_item(
- label=self._lbl_context_clear_cell,
- callback=lambda: self.call(
- self.on_clear_row,
- cell.row,
- cell.generator,
- ),
- )
- dpg.add_menu_item(
- label=self._lbl_context_clear_row,
- callback=lambda: self.call(self.on_clear_row, cell.row, None),
- )
+ def add_action_items(self, target: TrackerTarget) -> None:
+ """Builds every action a tracker cell offers, which is what the menu bar's Edit group asks for."""
+ self._menu.add_action_items(target)
def _keys_active(self) -> bool:
"""Whether the grid owns the next key: its tab is in front, its cursor is set, and no
@@ -1510,6 +1395,7 @@ def _keys_active(self) -> bool:
"""
return self._tab_active() and self._input_state.cursor is not None and not self._router.is_field_focused
+ # TODO: to extract common parts [_on_key_pressed]
def _on_key_pressed(self, event: KeyEvent) -> bool:
"""Applies a tracker key to the active cell, reporting whether the grid consumed it.
@@ -1535,7 +1421,7 @@ def _on_key_pressed(self, event: KeyEvent) -> bool:
if self._extend_selection(shortcut_id):
return True
- if self._select_shape(shortcut_id, cursor):
+ if self.select_shape(shortcut_id, cursor):
return True
if self._block_action(shortcut_id):
@@ -1598,7 +1484,7 @@ def _extend_selection(self, shortcut_id: ShortcutId) -> bool:
return True
- def _select_shape(
+ def select_shape(
self,
shortcut_id: ShortcutId,
cell: TrackerCursor,
@@ -1872,6 +1758,9 @@ def _type_character(self, event: KeyEvent) -> bool:
if Modifier.CTRL in event.modifiers or Modifier.ALT in event.modifiers:
return False
+ if self._type_note(event):
+ return True
+
char = HEX_KEYS.get(event.key) or SIGN_KEYS.get(event.key)
if char is None:
return False
@@ -1884,6 +1773,33 @@ def _type_character(self, event: KeyEvent) -> bool:
self._apply_state(new_state)
return True
+ def _type_note(self, event: KeyEvent) -> bool:
+ """Types the note a piano key names into the cell under the cursor.
+
+ The keys reach the pitch column of a channel that names notes: the sample column speaks
+ for a whole sample, whose channels rest at pitches of their own, and the noise channel
+ selects one of sixteen periods, which its own hex entry already writes.
+ """
+ cursor = self._input_state.cursor
+ if cursor is None or cursor.subcolumn is not SubColumn.TRANSPOSE or cursor.channel is None:
+ return False
+
+ if speaks_in_periods(cursor.channel):
+ return False
+
+ semitone = PIANO_KEYS.get(event.key)
+ if semitone is None:
+ return False
+
+ self.call(
+ self.on_note_typed,
+ cursor.row,
+ cursor.channel,
+ (self._octave + OCTAVE_OFFSET) * OCTAVE_SEMITONES + semitone,
+ )
+ self._apply_state(self._input_state.reset_pending().navigate_row(1, self._current_row_count))
+ return True
+
def _on_row_number_clicked(
self,
sender: Sender,
@@ -1892,13 +1808,13 @@ def _on_row_number_clicked(
) -> None:
dpg.set_value(sender, False)
existing = self._input_state.cursor
- generator = existing.generator if existing is not None else None
- subcolumn = existing.subcolumn if existing is not None else SubColumn.INSTRUMENT
+ channel = existing.channel if existing is not None else None
+ subcolumn = existing.subcolumn if existing is not None else SubColumn.VOICE
self._apply_state(
TrackerInputState(
cursor=TrackerCursor(
user_data,
- generator,
+ channel,
subcolumn,
),
pending="",
diff --git a/src/sampletones_application/ui/panels/sequencer/tracker/themes.py b/src/sampletones_application/ui/panels/sequencer/tracker/themes.py
new file mode 100644
index 000000000..296d9146e
--- /dev/null
+++ b/src/sampletones_application/ui/panels/sequencer/tracker/themes.py
@@ -0,0 +1,124 @@
+from typing import Dict, Optional
+
+from sampletones_application.layout.tabs.sequencer import SequencerLayout
+from sampletones_application.ui.panels.sequencer.tracker.callbacks import ThemeKey
+from sampletones_application.ui.themes.inline import (
+ create_header_selectable_theme,
+ create_label_selectable_theme,
+ create_selectable_text_theme,
+)
+from sampletones_application.utils.palette.colors.base import BaseColor
+from sampletones_application.utils.palette.colors.faded import FadedColor
+from sampletones_application.view_model.sequencer.subcolumn import SubColumn
+from sampletones_application.view_model.sequencer.voices import VoiceKind
+
+UNBUILT_THEME: int = 0
+
+
+class TrackerThemes:
+ """The shades a tracker cell, header and row number wear, built once and chosen per cell.
+
+ Every shade a cell can take exists in two forms — the full color and the dimmed one a silenced
+ channel wears — so choosing between them is a lookup rather than a color computed at each
+ bind. Building them needs a DearPyGui context, which is why :meth:`create` stands apart from
+ construction and is called once the panel's widget tree is being raised.
+ """
+
+ def __init__(self, layout: SequencerLayout) -> None:
+ self._layout = layout
+ self._subcolumn: Dict[ThemeKey, int] = {}
+ self._muted_subcolumn: Dict[ThemeKey, int] = {}
+ self._header: int = UNBUILT_THEME
+ self._muted_header: int = UNBUILT_THEME
+ self._column_label: int = UNBUILT_THEME
+ self._row_number: int = UNBUILT_THEME
+
+ def create(self) -> None:
+ """Builds every theme the grid binds, which a DearPyGui context has to stand behind."""
+ self._create_subcolumn_themes()
+ self._create_header_themes()
+ self._row_number = create_selectable_text_theme(self._layout.colors.text.row)
+
+ def _create_subcolumn_themes(self) -> None:
+ """Builds every text theme a cell can wear, in its full and its dimmed color.
+
+ The voice slot carries one theme per kind of voice it can name, beside the shade it takes
+ while it names none, so the color of a cell reports what stands in it. Transpose and volume
+ speak for themselves and take one each. The dimmed variant keeps the same hue at reduced
+ alpha, so a silenced channel's values stay readable and editable while the others are
+ worked on.
+ """
+ text = self._layout.colors.text
+ theme_colors: Dict[ThemeKey, BaseColor] = {
+ (SubColumn.VOICE, None): text.voice,
+ (SubColumn.VOICE, VoiceKind.SAMPLE): text.sample,
+ (SubColumn.VOICE, VoiceKind.INSTRUMENT): text.instrument,
+ (SubColumn.TRANSPOSE, None): text.transpose,
+ (SubColumn.VOLUME, None): text.volume,
+ }
+ fraction = self._layout.tracker.muted_text_fraction
+ for theme_key, color in theme_colors.items():
+ self._subcolumn[theme_key] = create_selectable_text_theme(color)
+ self._muted_subcolumn[theme_key] = create_selectable_text_theme(
+ FadedColor(
+ color=color,
+ fraction=fraction,
+ ),
+ )
+
+ def _create_header_themes(self) -> None:
+ """Builds the two shades a channel's header label takes: audible and silenced.
+
+ Both carry the header's own hover and press washes, so a label reads as the switch it is
+ while its text color reports whether the channel sounds.
+ """
+ header = self._layout.colors.header
+ self._header = create_header_selectable_theme(
+ self._layout.colors.label,
+ header.hovered,
+ header.active,
+ )
+ self._muted_header = create_header_selectable_theme(
+ self._layout.colors.muted.text,
+ header.hovered,
+ header.active,
+ )
+ self._column_label = create_label_selectable_theme(self._layout.colors.label)
+
+ def cell(
+ self,
+ subcolumn: SubColumn,
+ kind: Optional[VoiceKind],
+ *,
+ muted: bool,
+ ) -> int:
+ """The theme a cell wears: its slot's color, dimmed while its channel is silenced.
+
+ A voice slot takes the color of the kind of voice standing in it, so a reader tells a
+ recording from a hand-written one across the whole grid; a slot naming none takes the
+ neutral shade the other slots' colors are read against.
+
+ Args:
+ subcolumn: The slot the cell stands in.
+ kind: The kind of voice the slot names, ``None`` where it names none.
+ muted: Whether the cell's channel is silenced.
+
+ Returns:
+ int: The theme to bind.
+ """
+ themes = self._muted_subcolumn if muted else self._subcolumn
+ return themes[(subcolumn, kind)]
+
+ def header(self, *, muted: bool) -> int:
+ """The theme a column header wears, which reports whether its channel sounds."""
+ return self._muted_header if muted else self._header
+
+ @property
+ def column_label(self) -> int:
+ """The theme a column's own label wears."""
+ return self._column_label
+
+ @property
+ def row_number(self) -> int:
+ """The theme the row-number column wears."""
+ return self._row_number
diff --git a/src/sampletones_application/ui/panels/sequencer/voices/__init__.py b/src/sampletones_application/ui/panels/sequencer/voices/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/src/sampletones_application/ui/panels/sequencer/voices/footprint.py b/src/sampletones_application/ui/panels/sequencer/voices/footprint.py
new file mode 100644
index 000000000..a3a8b9304
--- /dev/null
+++ b/src/sampletones_application/ui/panels/sequencer/voices/footprint.py
@@ -0,0 +1,69 @@
+from typing import List, Optional, Tuple
+
+from sampletones_application.categories.context import (
+ channel_label,
+ context_label,
+ context_text,
+)
+from sampletones_application.categories.elements.global_ import ContextElements
+from sampletones_application.categories.hierarchy import TextType
+from sampletones_application.categories.manager import LanguageManager
+from sampletones_application.view_model.shared.footprint import VoiceFootprintViewModel
+from sampletones_core.constants.enums import ChannelName
+
+
+class VoiceFootprintText:
+ """The words a voice's byte figures are printed in, wherever a surface states them.
+
+ The right-click menu prints a figure per line and the status bar states one sentence, so both
+ read the same measurement through the same templates and a reader meets one figure for one
+ voice however they ask for it.
+ """
+
+ def __init__(self, language_manager: LanguageManager) -> None:
+ self._language_manager = language_manager
+ self._lbl_sample_size = context_label(language_manager, ContextElements.SAMPLE_SIZE)
+ self._tpl_size_bytes = context_text(language_manager, TextType.TEMPLATE, ContextElements.SIZE_BYTES)
+
+ def size(self, byte_count: int) -> str:
+ """One byte figure, as every surface prints it."""
+ return self._tpl_size_bytes.format(bytes=byte_count)
+
+ def items(
+ self,
+ footprint: Optional[VoiceFootprintViewModel],
+ ) -> List[Tuple[str, str]]:
+ """The byte figures a menu prints: the voice's total, then each channel that plays.
+
+ A channel standing by is written by no export, so it costs nothing and the figures name
+ the channels that do.
+
+ Args:
+ footprint: The measurement to print, or ``None`` where the pool holds no such voice.
+
+ Returns:
+ List[Tuple[str, str]]: Each figure as the label it is printed under and its value.
+ """
+ if footprint is None:
+ return []
+
+ items = [(self._lbl_sample_size, self.size(footprint.total_bytes))]
+ for channel_name in ChannelName.items():
+ instrument_bytes = footprint.bytes_for(channel_name)
+ if instrument_bytes is not None:
+ items.append(
+ (
+ channel_label(self._language_manager, channel_name),
+ self.size(instrument_bytes),
+ )
+ )
+
+ return items
+
+ def channels(self, footprint: VoiceFootprintViewModel) -> List[str]:
+ """The channels a voice plays, each named as every display naming a channel names it."""
+ return [
+ channel_label(self._language_manager, instrument.channel)
+ for instrument in footprint.instruments
+ if instrument.channel is not None
+ ]
diff --git a/src/sampletones_application/ui/panels/sequencer/voices/menu.py b/src/sampletones_application/ui/panels/sequencer/voices/menu.py
new file mode 100644
index 000000000..02019cf6f
--- /dev/null
+++ b/src/sampletones_application/ui/panels/sequencer/voices/menu.py
@@ -0,0 +1,330 @@
+from typing import Callable, Final, List, Optional, Protocol, Tuple
+
+import dearpygui.dearpygui as dpg
+
+from sampletones_application.categories.context import (
+ channel_label,
+ context_label,
+ context_text,
+)
+from sampletones_application.categories.elements.global_ import ContextElements
+from sampletones_application.categories.elements.sequencer import (
+ SequencerVoicesElements,
+)
+from sampletones_application.categories.hierarchy import Page, Panel, TextType
+from sampletones_application.categories.manager import LanguageManager
+from sampletones_application.ui.elements.context_menu import (
+ add_detail_items,
+ add_play_menu_item,
+ context_menu,
+)
+from sampletones_application.ui.elements.fonts.font import Font
+from sampletones_application.ui.elements.fonts.registry import FontRegistry
+from sampletones_application.ui.panels.sequencer.voices.footprint import (
+ VoiceFootprintText,
+)
+from sampletones_application.ui.panels.sequencer.voices.moves import (
+ VOICE_MOVES,
+ VoiceMove,
+)
+from sampletones_application.utils.gui.shortcuts.ids import ShortcutId
+from sampletones_application.utils.gui.shortcuts.source import ShortcutSource
+from sampletones_application.utils.palette.colors.base import BaseColor
+from sampletones_application.view_model.sequencer.voices import VoiceSelection
+from sampletones_application.view_model.shared.footprint import VoiceFootprintViewModel
+from sampletones_core.constants.enums import ChannelName
+from sampletones_shared.types.callback import StringCallback, VoidCallback
+from sampletones_shared.utils.callbacks import CallbackMixin
+
+NO_INSTRUMENTS: Final[Tuple[Optional[ChannelName], ...]] = ()
+
+NO_CHANNELS: Final[Tuple[ChannelName, ...]] = ()
+
+
+class VoicesMenuHost(Protocol):
+ """What the voices panel states to the menus raised over its list.
+
+ The hooks are the panel's own, so the coordinator keeps wiring them where it already does, and
+ a menu reads whichever answer stands at the moment it opens.
+ """
+
+ voice_footprint: Optional[Callable[[str], Optional[VoiceFootprintViewModel]]]
+ voice_instruments: Optional[Callable[[str], Tuple[Optional[ChannelName], ...]]]
+ instrument_channels: Optional[Callable[[str], Tuple[ChannelName, ...]]]
+ on_voice_edit_requested: Optional[StringCallback]
+ on_duplicate_requested: Optional[StringCallback]
+ on_remove_requested: Optional[StringCallback]
+ on_play_requested: Optional[StringCallback]
+ on_move_requested: Optional[Callable[[str, int], None]]
+ on_new_instrument_requested: Optional[VoidCallback]
+ on_add_sample_requested: Optional[VoidCallback]
+ on_import_instrument_requested: Optional[VoidCallback]
+ on_export_instrument_requested: Optional[Callable[[str, Optional[ChannelName]], None]]
+ on_instrument_from_channel_requested: Optional[Callable[[str, ChannelName], None]]
+
+ @property
+ def voice_count(self) -> int: ...
+
+ def start_rename(self, voice_id: str) -> None: ...
+
+
+class VoicesMenu(CallbackMixin):
+ """Every menu the voice list offers, wherever a reader raised it.
+
+ Two sections make up the whole: the ways a voice comes into the pool, and what the voice a
+ reader named can do. A door composes the sections it wants — the list's own menu prints the
+ pool alone, a row prints both, the menu bar's **Edit** menu prints the voice's actions and the
+ **Voice** group prints the pool above them — so an action is stated once and reaches all of
+ them.
+
+ What a menu offers about a voice is asked for as it opens: the byte figures, the instruments an
+ export would write, the channels a new instrument could be written from. Reading them at that
+ moment keeps what a menu prints and what a click does one answer.
+ """
+
+ def __init__(
+ self,
+ panel: VoicesMenuHost,
+ *,
+ language_manager: LanguageManager,
+ shortcut_source: ShortcutSource,
+ detail_color: BaseColor,
+ ) -> None:
+ self._panel = panel
+ self._language_manager = language_manager
+ self._shortcuts = shortcut_source
+ self._detail_color = detail_color
+ self._footprint_text = VoiceFootprintText(language_manager)
+ self._tip_size_bytes = context_text(language_manager, TextType.TOOLTIP, ContextElements.SIZE_BYTES)
+
+ def show_for(self, target: VoiceSelection) -> None:
+ """Raises the menu of one voice: what it is, what it costs, and what can be done with it.
+
+ A row is where a reader already is, so the ways a voice comes in stay within reach below
+ the voice's own actions.
+ """
+ with context_menu():
+ header = dpg.add_text(target.label)
+ FontRegistry.bind_to_item(header, Font.MONO_BOLD)
+ add_detail_items(
+ self._footprint_items(target.voice_id),
+ color=self._detail_color,
+ tooltip=self._tip_size_bytes,
+ )
+ dpg.add_separator()
+ add_play_menu_item(
+ context_label(self._language_manager, ContextElements.PLAY),
+ lambda: self.call(
+ self._panel.on_play_requested,
+ target.voice_id,
+ ),
+ )
+ dpg.add_separator()
+ self.add_action_items(target)
+ dpg.add_separator()
+ self.add_pool_items()
+
+ def show_pool(self) -> None:
+ """Raises the list's own menu, which prints the ways a voice comes in."""
+ with context_menu():
+ self.add_pool_items()
+
+ def add_pool_items(self) -> None:
+ """Builds the ways a voice comes into the pool, in the order each menu prints them.
+
+ A voice is written by hand, converted from a recording, or brought from a tracker, and
+ the three stand apart from the actions a listed voice offers, since each answers with an
+ entry the list did not hold. Every door onto the list prints this section, so a reader
+ reaches it from the list and from a row alike.
+ """
+ dpg.add_menu_item(
+ label=self._label(SequencerVoicesElements.NEW_INSTRUMENT),
+ shortcut=self._shortcuts.display(ShortcutId.NEW_INSTRUMENT),
+ callback=lambda: self.call(self._panel.on_new_instrument_requested),
+ )
+ dpg.add_menu_item(
+ label=self._label(SequencerVoicesElements.ADD_SAMPLE),
+ shortcut=self._shortcuts.display(ShortcutId.ADD_SAMPLE_FROM_FILE),
+ callback=lambda: self.call(self._panel.on_add_sample_requested),
+ )
+ dpg.add_menu_item(
+ label=self._label(SequencerVoicesElements.IMPORT_INSTRUMENT),
+ shortcut=self._shortcuts.display(ShortcutId.IMPORT_INSTRUMENT),
+ callback=lambda: self.call(self._panel.on_import_instrument_requested),
+ )
+
+ def add_action_items(self, target: VoiceSelection) -> None:
+ """Builds every action a voice offers, in the order each menu prints them.
+
+ The panel states its actions once, and whoever asks for them decides where they are shown:
+ the row menu asks for the voice a pointer landed on, and the menu bar asks for the one the
+ selection holds. An action added here reaches both, printing the key it answers to.
+ """
+ dpg.add_menu_item(
+ label=self._label(SequencerVoicesElements.CONTEXT_EDIT),
+ callback=lambda: self.call(self._panel.on_voice_edit_requested, target.voice_id),
+ )
+ dpg.add_menu_item(
+ label=self._label(SequencerVoicesElements.CONTEXT_RENAME),
+ shortcut=self._shortcuts.display(ShortcutId.VOICES_RENAME_VOICE),
+ callback=lambda: self._panel.start_rename(target.voice_id),
+ )
+ dpg.add_menu_item(
+ label=self._label(SequencerVoicesElements.CONTEXT_DUPLICATE),
+ callback=lambda: self.call(self._panel.on_duplicate_requested, target.voice_id),
+ )
+ self._add_instrument_from_items(target)
+ dpg.add_separator()
+ dpg.add_menu_item(
+ label=self._label(SequencerVoicesElements.CONTEXT_REMOVE),
+ shortcut=self._shortcuts.display(ShortcutId.VOICES_REMOVE_VOICE),
+ callback=lambda: self.call(self._panel.on_remove_requested, target.voice_id),
+ )
+ dpg.add_separator()
+ for move in VOICE_MOVES:
+ self._add_move_item(move, target)
+
+ dpg.add_separator()
+ self._add_export_items(target)
+
+ def _add_instrument_from_items(self, target: VoiceSelection) -> None:
+ """Offers the channels of the voice a new instrument can be written from.
+
+ A recording's channel carries frames of its own, so each of them makes a voice the reader
+ can edit as envelopes. The channel names what the new instrument plays, so it is always the
+ submenu that says which one, however few of them the voice offers. A voice that is already
+ a set of envelopes offers the item unreachable, which says the action exists while leaving
+ it where it belongs.
+ """
+ label = self._label(SequencerVoicesElements.CONTEXT_INSTRUMENT_FROM)
+ channels = self.query(self._panel.instrument_channels, target.voice_id, default=NO_CHANNELS)
+ if not channels:
+ dpg.add_menu_item(label=label, enabled=False)
+ return
+
+ with dpg.menu(label=label):
+ for channel_name in channels:
+ self._add_instrument_from_channel_item(target, channel_name)
+
+ def _add_instrument_from_channel_item(
+ self,
+ target: VoiceSelection,
+ channel_name: ChannelName,
+ ) -> None:
+ dpg.add_menu_item(
+ label=channel_label(self._language_manager, channel_name),
+ callback=lambda: self.call(
+ self._panel.on_instrument_from_channel_requested,
+ target.voice_id,
+ channel_name,
+ ),
+ )
+
+ def _add_export_items(self, target: VoiceSelection) -> None:
+ """Offers the instruments the voice would be written as, however many of them it holds.
+
+ The menu asks how many instruments the voice contains rather than which kind of voice it
+ is, so one item stands where there is nothing to choose and a channel submenu stands where
+ a reader picks between slices. A voice writing nothing offers the item unreachable, which
+ says an export exists without pretending this voice has one.
+ """
+ label = self._label(SequencerVoicesElements.CONTEXT_EXPORT_INSTRUMENT)
+ channels = self.query(self._panel.voice_instruments, target.voice_id, default=NO_INSTRUMENTS)
+ if not channels:
+ dpg.add_menu_item(label=label, enabled=False)
+ return
+
+ if len(channels) > 1:
+ with dpg.menu(label=label):
+ for channel_name in channels:
+ self._add_export_channel_item(target, channel_name)
+
+ return
+
+ dpg.add_menu_item(
+ label=label,
+ callback=lambda: self._request_export(target.voice_id, channels[0]),
+ )
+
+ def _add_export_channel_item(
+ self,
+ target: VoiceSelection,
+ channel_name: Optional[ChannelName],
+ ) -> None:
+ """Offers one of a voice's instruments, under the name that instrument carries."""
+ dpg.add_menu_item(
+ label=self._instrument_label(target, channel_name),
+ callback=lambda: self._request_export(
+ target.voice_id,
+ channel_name,
+ ),
+ )
+
+ def _instrument_label(
+ self,
+ target: VoiceSelection,
+ channel_name: Optional[ChannelName],
+ ) -> str:
+ """The name one of a voice's instruments is listed under.
+
+ A slice is named by the channel it was reconstructed for, which is what tells a voice's
+ slices apart; an instrument stated for every channel alike is named after the voice.
+ """
+ if channel_name is None:
+ return target.name
+
+ return channel_label(self._language_manager, channel_name)
+
+ def _request_export(
+ self,
+ voice_id: str,
+ channel_name: Optional[ChannelName],
+ ) -> None:
+ self.call(
+ self._panel.on_export_instrument_requested,
+ voice_id,
+ channel_name,
+ )
+
+ def _add_move_item(
+ self,
+ move: VoiceMove,
+ target: VoiceSelection,
+ ) -> None:
+ """Builds one move item, offered while the move carries the voice somewhere new."""
+ position = move.direction.target(target.position, self._panel.voice_count)
+ dpg.add_menu_item(
+ label=self._label(move.element),
+ shortcut=self._shortcuts.display(move.shortcut),
+ enabled=position is not None,
+ callback=lambda: self.call(
+ self._panel.on_move_requested,
+ target.voice_id,
+ position,
+ ),
+ )
+
+ def _footprint_items(
+ self,
+ voice_id: str,
+ ) -> List[Tuple[str, str]]:
+ """The byte figures the menu prints for a voice, asked for as the menu opens.
+
+ Reading them at that moment keeps them naming what the voice occupies while a reader is
+ looking at it.
+ """
+ return self._footprint_text.items(
+ self.query(
+ self._panel.voice_footprint,
+ voice_id,
+ default=None,
+ )
+ )
+
+ def _label(self, element: SequencerVoicesElements) -> str:
+ return self._language_manager[
+ Page.SEQUENCER,
+ Panel.VOICES,
+ TextType.LABEL,
+ element,
+ ]
diff --git a/src/sampletones_application/ui/panels/sequencer/voices/moves.py b/src/sampletones_application/ui/panels/sequencer/voices/moves.py
new file mode 100644
index 000000000..bc711522c
--- /dev/null
+++ b/src/sampletones_application/ui/panels/sequencer/voices/moves.py
@@ -0,0 +1,43 @@
+from dataclasses import dataclass
+from typing import Dict, Final, Tuple
+
+from sampletones_application.categories.elements.sequencer import (
+ SequencerVoicesElements,
+)
+from sampletones_application.utils.gui.shortcuts.ids import ShortcutId
+from sampletones_application.view_model.sequencer.move import MoveDirection
+
+
+@dataclass(frozen=True)
+class VoiceMove:
+ """One of the four moves, as its key press and its menu item each name it."""
+
+ element: SequencerVoicesElements
+ shortcut: ShortcutId
+ direction: MoveDirection
+
+
+VOICE_MOVES: Final[Tuple[VoiceMove, ...]] = (
+ VoiceMove(
+ element=SequencerVoicesElements.CONTEXT_MOVE_UP,
+ shortcut=ShortcutId.VOICES_MOVE_VOICE_UP,
+ direction=MoveDirection.PREVIOUS,
+ ),
+ VoiceMove(
+ element=SequencerVoicesElements.CONTEXT_MOVE_DOWN,
+ shortcut=ShortcutId.VOICES_MOVE_VOICE_DOWN,
+ direction=MoveDirection.NEXT,
+ ),
+ VoiceMove(
+ element=SequencerVoicesElements.CONTEXT_MOVE_TOP,
+ shortcut=ShortcutId.VOICES_MOVE_VOICE_TO_TOP,
+ direction=MoveDirection.FIRST,
+ ),
+ VoiceMove(
+ element=SequencerVoicesElements.CONTEXT_MOVE_BOTTOM,
+ shortcut=ShortcutId.VOICES_MOVE_VOICE_TO_BOTTOM,
+ direction=MoveDirection.LAST,
+ ),
+)
+
+MOVE_DIRECTIONS: Final[Dict[ShortcutId, MoveDirection]] = {move.shortcut: move.direction for move in VOICE_MOVES}
diff --git a/src/sampletones_application/ui/panels/sequencer/voices/panel.py b/src/sampletones_application/ui/panels/sequencer/voices/panel.py
new file mode 100644
index 000000000..62344e530
--- /dev/null
+++ b/src/sampletones_application/ui/panels/sequencer/voices/panel.py
@@ -0,0 +1,711 @@
+from typing import Callable, Final, List, Optional, Tuple
+
+import dearpygui.dearpygui as dpg
+
+from sampletones_application.categories.elements.sequencer import (
+ SequencerVoicesElements,
+)
+from sampletones_application.categories.hierarchy import Page, Panel, TextType
+from sampletones_application.categories.manager import LanguageManager
+from sampletones_application.layout.tabs.sequencer import SequencerLayout
+from sampletones_application.tags.compose import compose_tag
+from sampletones_application.tags.general import SUF_HANDLER_LIST, SUF_HANDLER_REGISTRY
+from sampletones_application.tags.sequencer import (
+ TAG_SEQUENCER_VOICES_BUTTON_NEW_INSTRUMENT,
+ TAG_SEQUENCER_VOICES_INPUT_RENAME,
+ TAG_SEQUENCER_VOICES_PANEL,
+ TAG_SEQUENCER_VOICES_TABLE,
+ TAG_SEQUENCER_VOICES_THEME_ROW,
+ TAG_SEQUENCER_VOICES_WINDOW,
+)
+from sampletones_application.ui.elements.fonts.font import Font
+from sampletones_application.ui.elements.fonts.registry import FontRegistry
+from sampletones_application.ui.elements.panel import GUIPanel
+from sampletones_application.ui.elements.status import GUIStatusBar
+from sampletones_application.ui.panels.sequencer.voices.footprint import (
+ VoiceFootprintText,
+)
+from sampletones_application.ui.panels.sequencer.voices.menu import VoicesMenu
+from sampletones_application.ui.panels.sequencer.voices.moves import MOVE_DIRECTIONS
+from sampletones_application.ui.themes.registry import ThemeRegistry
+from sampletones_application.utils.gui.dpg import dpg_delete_children, dpg_pointer_within_window
+from sampletones_application.utils.gui.frame import FrameCallbackManager
+from sampletones_application.utils.gui.keyboard import (
+ PRIORITY_PANEL,
+ ActivePredicate,
+ KeyEvent,
+ KeyRouter,
+)
+from sampletones_application.utils.gui.palette.dpg import dpg_set_palette_color
+from sampletones_application.utils.gui.shortcuts.ids import ShortcutCategory, ShortcutId
+from sampletones_application.utils.gui.shortcuts.source import ShortcutSource
+from sampletones_application.utils.gui.tooltip import show_tooltip
+from sampletones_application.utils.palette.colors.base import BaseColor
+from sampletones_application.view_model.sequencer.voices import (
+ SequencerVoicesViewModel,
+ VoiceEntryViewModel,
+ VoiceKind,
+ VoiceSelection,
+)
+from sampletones_application.view_model.shared.footprint import VoiceFootprintViewModel
+from sampletones_core.constants.enums import ChannelName
+from sampletones_core.utils.display import display_id
+from sampletones_shared.types.application import Sender
+from sampletones_shared.types.callback import StringCallback, VoidCallback
+
+FROZEN_HEADER_ROWS: Final[int] = 1
+
+
+class GUISequencerVoicesPanel(GUIPanel):
+ def __init__(
+ self,
+ *,
+ layout: SequencerLayout,
+ detail_color: BaseColor,
+ language_manager: LanguageManager,
+ status_bar: GUIStatusBar,
+ key_router: KeyRouter,
+ tab_active: ActivePredicate,
+ shortcut_source: ShortcutSource,
+ initial_collapsed: bool = False,
+ ) -> None:
+ self._language_manager = language_manager
+ self._status_bar = status_bar
+ self._layout = layout
+ self._router = key_router
+ self._tab_active = tab_active
+ self._shortcuts = shortcut_source
+ self._row_handler_tag = compose_tag(TAG_SEQUENCER_VOICES_TABLE, SUF_HANDLER_REGISTRY)
+ self._rename_handler_tag = compose_tag(TAG_SEQUENCER_VOICES_INPUT_RENAME, SUF_HANDLER_REGISTRY)
+ self._list_handler_tag = compose_tag(TAG_SEQUENCER_VOICES_WINDOW, SUF_HANDLER_LIST)
+ self._list_menu_pending = False
+ self._selected_voice_id: Optional[str] = None
+ self._selected_row: Optional[int] = None
+ self._editing_voice_id: Optional[str] = None
+ self._entries: Tuple[VoiceEntryViewModel, ...] = ()
+ self._tip_new_instrument = self._tooltip(language_manager, SequencerVoicesElements.NEW_INSTRUMENT)
+ self._tip_kind_sample = self._tooltip(language_manager, SequencerVoicesElements.KIND_SAMPLE)
+ self._tip_kind_instrument = self._tooltip(language_manager, SequencerVoicesElements.KIND_INSTRUMENT)
+ self._footprint_text = VoiceFootprintText(language_manager)
+ self._tpl_status_sample = language_manager["sequencer.voices.template.status_sample"]
+ self._tpl_status_instrument = language_manager["sequencer.voices.template.status_instrument"]
+ self._channel_separator = language_manager["sequencer.voices.template.status_channel_separator"]
+ self.voice_footprint: Optional[Callable[[str], Optional[VoiceFootprintViewModel]]] = None
+ self.voice_instruments: Optional[Callable[[str], Tuple[Optional[ChannelName], ...]]] = None
+ self.instrument_channels: Optional[Callable[[str], Tuple[ChannelName, ...]]] = None
+ self.on_voice_selected: Optional[StringCallback] = None
+ self.on_voice_edit_requested: Optional[StringCallback] = None
+ self.on_remove_requested: Optional[StringCallback] = None
+ self.on_play_requested: Optional[StringCallback] = None
+ self.on_move_requested: Optional[Callable[[str, int], None]] = None
+ self.on_rename_committed: Optional[Callable[[str, str], None]] = None
+ self.on_duplicate_requested: Optional[StringCallback] = None
+ self.on_new_instrument_requested: Optional[VoidCallback] = None
+ self.on_add_sample_requested: Optional[VoidCallback] = None
+ self.on_import_instrument_requested: Optional[VoidCallback] = None
+ self.on_export_instrument_requested: Optional[Callable[[str, Optional[ChannelName]], None]] = None
+ self.on_instrument_from_channel_requested: Optional[Callable[[str, ChannelName], None]] = None
+ self._menu = VoicesMenu(
+ self,
+ language_manager=language_manager,
+ shortcut_source=shortcut_source,
+ detail_color=detail_color,
+ )
+
+ super().__init__(
+ tag=TAG_SEQUENCER_VOICES_PANEL,
+ width=-1,
+ height=-layout.history.height,
+ )
+ self._enable_vertical_collapse(initial_collapsed=initial_collapsed)
+
+ def create_panel(self, parent: str) -> None:
+ with self._collapsible_card(
+ parent,
+ self._label(self._language_manager, SequencerVoicesElements.VOICES_TEXT),
+ glyph=self._glyphs.headers.voices,
+ ):
+ self._create_new_instrument_button()
+ self._create_voices_table()
+
+ self._create_row_handlers()
+ self._create_list_handler()
+ self._create_rename_handler()
+ self._create_key_handler()
+
+ def _create_row_handlers(self) -> None:
+ with dpg.item_handler_registry(tag=self._row_handler_tag):
+ dpg.add_item_clicked_handler(callback=self._on_voice_clicked)
+ dpg.add_item_double_clicked_handler(callback=self._on_voice_double_clicked)
+ dpg.add_item_hover_handler(callback=self._on_row_hovered)
+
+ def _on_row_hovered(self, _sender: Sender, app_data: int) -> None:
+ """Says what the hovered row holds, which the id cell and the name cell both address.
+
+ Both cells carry the row's position and its voice, so one handler covers the whole row and
+ a reader reads the same line wherever the pointer rests on it.
+ """
+ user_data = dpg.get_item_user_data(app_data)
+ if not isinstance(user_data, tuple):
+ return
+
+ _position, voice_id = user_data
+ self._status_bar.set(self._voice_status_message(voice_id))
+
+ def _voice_status_message(self, voice_id: str) -> str:
+ """What a voice is, what it plays and what it costs, as one sentence a reader reads.
+
+ A recording plays the channels its conversion found and exports an instrument for each of
+ them; a hand-written voice is one set of envelopes every channel reads, so it names no
+ channel and carries a single figure.
+ """
+ entry = self._entry_for(voice_id)
+ footprint = self.query(self.voice_footprint, voice_id, default=None)
+ if entry is None or footprint is None:
+ return ""
+
+ size = self._footprint_text.size(footprint.total_bytes)
+ match entry.kind:
+ case VoiceKind.SAMPLE:
+ return self._tpl_status_sample.format(
+ name=entry.name,
+ channels=self._channel_separator.join(self._footprint_text.channels(footprint)),
+ bytes=size,
+ )
+ case VoiceKind.INSTRUMENT:
+ return self._tpl_status_instrument.format(name=entry.name, bytes=size)
+
+ def _create_list_handler(self) -> None:
+ """Answers a press that lands on the list itself rather than on one of its rows."""
+ with dpg.handler_registry(tag=self._list_handler_tag):
+ dpg.add_mouse_click_handler(
+ button=dpg.mvMouseButton_Right,
+ callback=self._on_list_right_clicked,
+ )
+
+ def _create_rename_handler(self) -> None:
+ with dpg.item_handler_registry(tag=self._rename_handler_tag):
+ dpg.add_item_deactivated_handler(callback=self._on_rename_deactivated)
+
+ def _create_key_handler(self) -> None:
+ self._router.register(
+ self._on_key_pressed,
+ priority=PRIORITY_PANEL,
+ active=self._keys_active,
+ )
+
+ def _create_new_instrument_button(self) -> None:
+ """Offers a hand-written voice, which is the one kind no browser brings in."""
+ button = dpg.add_button(
+ tag=TAG_SEQUENCER_VOICES_BUTTON_NEW_INSTRUMENT,
+ label=self._label(self._language_manager, SequencerVoicesElements.NEW_INSTRUMENT),
+ width=-1,
+ callback=lambda: self.call(self.on_new_instrument_requested),
+ )
+ FontRegistry.bind_to_item(button, Font.REGULAR_SMALL)
+ show_tooltip(button, self._tip_new_instrument)
+
+ def _create_voices_table(self) -> None:
+ with (
+ dpg.child_window(
+ tag=TAG_SEQUENCER_VOICES_WINDOW,
+ border=False,
+ width=-1,
+ height=-1,
+ ),
+ dpg.table(
+ tag=TAG_SEQUENCER_VOICES_TABLE,
+ width=-1,
+ height=-1,
+ header_row=True,
+ resizable=False,
+ borders_innerH=False,
+ borders_innerV=True,
+ borders_outerH=True,
+ borders_outerV=True,
+ scrollY=True,
+ freeze_rows=FROZEN_HEADER_ROWS,
+ row_background=True,
+ policy=dpg.mvTable_SizingFixedFit,
+ ),
+ ):
+ dpg.add_table_column(
+ label=self._label(
+ self._language_manager,
+ SequencerVoicesElements.COLUMN_KIND,
+ ),
+ width_fixed=True,
+ init_width_or_weight=self._layout.table_cells.voice.kind,
+ )
+ dpg.add_table_column(
+ label=self._label(
+ self._language_manager,
+ SequencerVoicesElements.COLUMN_ID,
+ ),
+ width_fixed=True,
+ init_width_or_weight=self._layout.table_cells.voice.id,
+ )
+ dpg.add_table_column(
+ label=self._label(
+ self._language_manager,
+ SequencerVoicesElements.COLUMN_NAME,
+ ),
+ width_stretch=True,
+ init_width_or_weight=self._layout.table_cells.voice.name,
+ )
+ ThemeRegistry.get(TAG_SEQUENCER_VOICES_THEME_ROW).bind_to_item(TAG_SEQUENCER_VOICES_TABLE)
+
+ def update_view(self, view_model: SequencerVoicesViewModel) -> None:
+ self._entries = view_model.voices
+ self._editing_voice_id = None
+ self._rebuild()
+
+ def _rebuild(self) -> None:
+ """Rebuilds the voices table from the cached entries with explicit parents.
+
+ Items pass an explicit ``parent`` so each widget binds directly: the browser
+ tree builds on a worker thread and the DearPyGui container stack is
+ process-global, so explicit parents keep this build independent of that
+ shared stack.
+ """
+ dpg_delete_children(TAG_SEQUENCER_VOICES_TABLE, slot=1)
+ self._selected_row = None
+ for position, entry in enumerate(self._entries):
+ self._build_voice_row(position, entry)
+ if self._selected_row is None:
+ self._selected_voice_id = None
+
+ def _build_voice_row(
+ self,
+ position: int,
+ entry: VoiceEntryViewModel,
+ ) -> None:
+ row_id = dpg.add_table_row(parent=TAG_SEQUENCER_VOICES_TABLE)
+ self._build_kind_cell(row_id, entry)
+ self._build_id_cell(row_id, position, entry)
+ self._build_name_cell(row_id, position, entry)
+ if entry.voice_id == self._selected_voice_id:
+ self._selected_row = position
+ self._highlight_selected_row(position)
+
+ def _highlight_selected_row(self, position: int) -> None:
+ dpg.highlight_table_row(
+ TAG_SEQUENCER_VOICES_TABLE,
+ position,
+ color=self._layout.colors.cell_cursor.rgba,
+ )
+
+ def repaint(self) -> None:
+ """Issues the selected row's tint again so it takes the palette now in place.
+
+ DearPyGui keeps a row highlight on the table rather than on an item, so the color
+ reaches it only by being pushed again.
+ """
+ if self._selected_row is None or not dpg.does_item_exist(TAG_SEQUENCER_VOICES_TABLE):
+ return
+
+ self._highlight_selected_row(self._selected_row)
+
+ def _build_kind_cell(
+ self,
+ row_id: int | str,
+ entry: VoiceEntryViewModel,
+ ) -> None:
+ """Marks which kind the row carries, so a converted voice reads apart from a written one.
+
+ The glyph names the kind and its color repeats it, which is the same pair the tracker's
+ voice slot wears — so a row and the cells naming it read as one thing across the two panels.
+ """
+ kind_cell = dpg.add_table_cell(parent=row_id)
+ mark = dpg.add_text(
+ parent=kind_cell,
+ default_value=self._kind_glyph(entry.kind),
+ )
+ FontRegistry.bind_to_item(mark, Font.ICON)
+ dpg_set_palette_color(mark, self._kind_color(entry.kind))
+ show_tooltip(mark, self._kind_tooltip(entry.kind))
+
+ def _kind_glyph(self, kind: VoiceKind) -> str:
+ match kind:
+ case VoiceKind.SAMPLE:
+ return self._glyphs.voices.sample
+ case VoiceKind.INSTRUMENT:
+ return self._glyphs.voices.instrument
+
+ def _kind_color(self, kind: VoiceKind) -> BaseColor:
+ text = self._layout.colors.text
+ match kind:
+ case VoiceKind.SAMPLE:
+ return text.sample
+ case VoiceKind.INSTRUMENT:
+ return text.instrument
+
+ def _kind_tooltip(self, kind: VoiceKind) -> str:
+ match kind:
+ case VoiceKind.SAMPLE:
+ return self._tip_kind_sample
+ case VoiceKind.INSTRUMENT:
+ return self._tip_kind_instrument
+
+ def _build_id_cell(
+ self,
+ row_id: int | str,
+ position: int,
+ entry: VoiceEntryViewModel,
+ ) -> None:
+ id_cell = dpg.add_table_cell(parent=row_id)
+ id_selectable = dpg.add_selectable(
+ parent=id_cell,
+ label=display_id(position),
+ user_data=(position, entry.voice_id),
+ callback=self._on_voice_selected,
+ )
+ FontRegistry.bind_to_item(id_selectable, Font.MONO_SMALL)
+ dpg.bind_item_handler_registry(id_selectable, self._row_handler_tag)
+
+ def _build_name_cell(
+ self,
+ row_id: int | str,
+ position: int,
+ entry: VoiceEntryViewModel,
+ ) -> None:
+ name_cell = dpg.add_table_cell(parent=row_id)
+ if entry.voice_id == self._editing_voice_id:
+ self._build_name_input(name_cell, entry)
+ else:
+ self._build_name_selectable(name_cell, position, entry)
+
+ def _build_name_selectable(
+ self,
+ name_cell: int | str,
+ position: int,
+ entry: VoiceEntryViewModel,
+ ) -> None:
+ name_selectable = dpg.add_selectable(
+ parent=name_cell,
+ label=entry.name,
+ user_data=(position, entry.voice_id),
+ callback=self._on_voice_selected,
+ )
+ FontRegistry.bind_to_item(name_selectable, Font.MONO_SMALL)
+ dpg.bind_item_handler_registry(name_selectable, self._row_handler_tag)
+
+ def _build_name_input(
+ self,
+ name_cell: int | str,
+ entry: VoiceEntryViewModel,
+ ) -> None:
+ name_input = dpg.add_input_text(
+ tag=TAG_SEQUENCER_VOICES_INPUT_RENAME,
+ parent=name_cell,
+ default_value=entry.name,
+ width=-1,
+ on_enter=True,
+ callback=self._on_rename_enter,
+ )
+ FontRegistry.bind_to_item(name_input, Font.MONO_SMALL)
+ dpg.bind_item_handler_registry(name_input, self._rename_handler_tag)
+
+ def _on_voice_selected(
+ self,
+ sender: Sender,
+ _app_data: bool,
+ user_data: Tuple[int, str],
+ ) -> None:
+ position, voice_id = user_data
+ dpg.set_value(sender, False)
+ if self._selected_row is not None:
+ dpg.unhighlight_table_row(
+ TAG_SEQUENCER_VOICES_TABLE,
+ self._selected_row,
+ )
+
+ self._selected_row = position
+ self._selected_voice_id = voice_id
+ self._highlight_selected_row(position)
+ self.call(self.on_voice_selected, voice_id)
+
+ @property
+ def selection(self) -> Optional[VoiceSelection]:
+ """The selected voice, or ``None`` while the panel holds no selection.
+
+ Derived from the highlighted row and the cached entries on each read, so it reports
+ whatever the table currently shows. Lets an operation hosted by another panel of the tab
+ address the selection without keeping a copy of it.
+ """
+ if self._selected_voice_id is None or self._selected_row is None:
+ return None
+
+ entry = self._entry_for(self._selected_voice_id)
+ if entry is None:
+ return None
+
+ return VoiceSelection(
+ voice_id=entry.voice_id,
+ position=self._selected_row,
+ name=entry.name,
+ kind=entry.kind,
+ )
+
+ def deselect(self) -> None:
+ """Drops the voice selection so the panel stops consuming keystrokes.
+
+ Mirrors the grid and order panels: each registers a key-router scope that is active only
+ while it holds a selection, so a single selection across the three decides which one acts
+ on a keystroke. Selecting a cell in another panel clears this one's selection via this method.
+ """
+ if self._selected_row is not None:
+ dpg.unhighlight_table_row(
+ TAG_SEQUENCER_VOICES_TABLE,
+ self._selected_row,
+ )
+
+ self._selected_row = None
+ self._selected_voice_id = None
+
+ def _keys_active(self) -> bool:
+ """Whether the voices panel owns the next key.
+
+ The panel answers only while its tab is in front, since a selection outlives a move to
+ another tab. There, a name being edited keeps the keyboard so Escape can cancel the rename;
+ otherwise the panel acts when a voice is selected and no field holds the keyboard. A modal
+ dialog claims keys at a higher priority in the router, so the panel needs no modal check.
+ """
+ if not self._tab_active():
+ return False
+
+ if self._editing_voice_id is not None:
+ return True
+
+ return self._selected_voice_id is not None and not self._router.is_field_focused
+
+ def _on_key_pressed(self, event: KeyEvent) -> bool:
+ """Applies a voices key to the selected voice, reporting whether the panel consumed it.
+
+ The scheme says which press each voices action answers to; a press the voices category
+ leaves unnamed goes to the application's global shortcuts.
+ """
+ shortcut_id = self._shortcuts.action(ShortcutCategory.VOICES, event)
+ if self._editing_voice_id is not None:
+ return self._cancel_edit(shortcut_id)
+
+ voice_id = self._selected_voice_id
+ if voice_id is None or shortcut_id is None:
+ return False
+
+ if self._move_voice(shortcut_id):
+ return True
+
+ match shortcut_id:
+ case ShortcutId.VOICES_REMOVE_VOICE:
+ self.call(self.on_remove_requested, voice_id)
+ case ShortcutId.VOICES_RENAME_VOICE:
+ self.start_rename(voice_id)
+ case _:
+ return False
+
+ return True
+
+ def _cancel_edit(self, shortcut_id: Optional[ShortcutId]) -> bool:
+ """Drops the name being edited, reporting whether the press was the cancel.
+
+ A rename in progress keeps every other key for the input, so typing a name reaches the
+ field rather than the panel.
+ """
+ if shortcut_id is not ShortcutId.VOICES_CANCEL_RENAME:
+ return False
+
+ self._cancel_rename()
+ return True
+
+ def _move_voice(self, shortcut_id: ShortcutId) -> bool:
+ """Moves the selected voice up, down, to the top or to the bottom of the list.
+
+ Returns whether the action was one of the moves, so a boundary with nowhere to go still
+ counts as consumed and stays out of the global shortcuts.
+ """
+ direction = MOVE_DIRECTIONS.get(shortcut_id)
+ if direction is None or self._selected_voice_id is None or self._selected_row is None:
+ return False
+
+ target = direction.target(self._selected_row, len(self._entries))
+ if target is not None:
+ self.call(self.on_move_requested, self._selected_voice_id, target)
+
+ return True
+
+ def start_rename(self, voice_id: str) -> None:
+ """Turns the voice's name cell into a focused text input."""
+ if self._entry_for(voice_id) is None:
+ return
+
+ self._editing_voice_id = voice_id
+ self._rebuild()
+ FrameCallbackManager.set_frame_callback(lambda: dpg.focus_item(TAG_SEQUENCER_VOICES_INPUT_RENAME))
+
+ def _commit_rename(self) -> None:
+ """Applies the edited name and restores the read-only cell.
+
+ Clears the edit before notifying so the input's deactivated handler, fired
+ during the teardown rebuild, sees the edit already finished.
+ """
+ if self._editing_voice_id is None:
+ return
+
+ voice_id = self._editing_voice_id
+ name = dpg.get_value(TAG_SEQUENCER_VOICES_INPUT_RENAME)
+ self._editing_voice_id = None
+ self.call(self.on_rename_committed, voice_id, name)
+ self._rebuild()
+
+ def _cancel_rename(self) -> None:
+ if self._editing_voice_id is None:
+ return
+
+ self._editing_voice_id = None
+ self._rebuild()
+
+ def _on_rename_enter(self, _sender: Sender, _app_data: str) -> None:
+ self._commit_rename()
+
+ def _on_rename_deactivated(self, _sender: Sender, _app_data: int) -> None:
+ self._commit_rename()
+
+ def _on_voice_double_clicked(
+ self,
+ _sender: Sender,
+ app_data: List[int],
+ ) -> None:
+ clicked_item = app_data[1]
+ user_data = dpg.get_item_user_data(clicked_item)
+ if user_data is not None:
+ _, voice_id = user_data
+ self.call(self.on_voice_edit_requested, voice_id)
+
+ def _on_voice_clicked(
+ self,
+ _sender: Sender,
+ app_data: Tuple[int, int],
+ ) -> None:
+ mouse_button, clicked_item = app_data
+ if mouse_button != dpg.mvMouseButton_Right:
+ return
+
+ user_data = dpg.get_item_user_data(clicked_item)
+ if user_data is None:
+ return
+
+ position, voice_id = user_data
+ self._list_menu_pending = False
+ self._show_context_menu(position, voice_id)
+
+ def _on_list_right_clicked(
+ self,
+ _sender: Sender,
+ _app_data: int,
+ ) -> None:
+ """Raises the list's own menu a frame later, leaving a row that answers the press first.
+
+ Both doors are offered the same press, the list before the row, so the menu the list
+ would raise waits a frame: a row landed on claims the press in the meantime and states
+ the voice it holds, and an empty stretch of the list leaves the claim unmade.
+ """
+ if not self._pointer_within_list():
+ return
+
+ self._list_menu_pending = True
+ FrameCallbackManager.set_frame_callback(self._show_list_menu)
+
+ def _pointer_within_list(self) -> bool:
+ """Whether the pointer stands over the voice list.
+
+ The button above the list is laid out in the card that holds them both, so where the
+ button was drawn places the list on screen as well.
+ """
+ return dpg_pointer_within_window(
+ TAG_SEQUENCER_VOICES_WINDOW,
+ TAG_SEQUENCER_VOICES_BUTTON_NEW_INSTRUMENT,
+ )
+
+ def _show_list_menu(self) -> None:
+ """Prints the ways a voice comes in, for a press the list answered."""
+ if not self._list_menu_pending:
+ return
+
+ self._list_menu_pending = False
+ self._menu.show_pool()
+
+ def _entry_for(self, voice_id: str) -> Optional[VoiceEntryViewModel]:
+ return next((entry for entry in self._entries if entry.voice_id == voice_id), None)
+
+ def _show_context_menu(self, position: int, voice_id: str) -> None:
+ """Raises the menu of the row a press landed on, for the voice that row holds."""
+ entry = self._entry_for(voice_id)
+ if entry is None:
+ return
+
+ self._menu.show_for(
+ VoiceSelection(
+ voice_id=voice_id,
+ position=position,
+ name=entry.name,
+ kind=entry.kind,
+ )
+ )
+
+ @property
+ def voice_count(self) -> int:
+ """How many voices the list holds, which is what says where a move can carry one."""
+ return len(self._entries)
+
+ def owns_edit_actions(self) -> bool:
+ """Whether the Edit menu states this panel's actions, which it does while it holds a voice.
+
+ The menu offers what the next press would reach, so the key scope decides it, and the
+ selection those keys act on is the one the actions are built for.
+ """
+ return self._keys_active() and self.selection is not None
+
+ def build_edit_actions(self) -> None:
+ """Builds the panel's whole action set for the voice the selection holds."""
+ selection = self.selection
+ if selection is not None:
+ self._menu.add_action_items(selection)
+
+ def build_voice_actions(self) -> None:
+ """States the actions of the voice the list holds, for a menu listing the pool above them.
+
+ The Voice menu prints the ways a voice comes in first, so the chosen voice's actions are
+ led by a rule of their own here, and nothing is stated while no voice is chosen. The row
+ menu draws its own dividers around the same set.
+ """
+ selection = self.selection
+ if selection is None:
+ return
+
+ dpg.add_separator()
+ self._menu.add_action_items(selection)
+
+ @staticmethod
+ def _label(
+ language_manager: LanguageManager,
+ element: SequencerVoicesElements,
+ ) -> str:
+ return language_manager[
+ Page.SEQUENCER,
+ Panel.VOICES,
+ TextType.LABEL,
+ element,
+ ]
+
+ @staticmethod
+ def _tooltip(
+ language_manager: LanguageManager,
+ element: SequencerVoicesElements,
+ ) -> str:
+ return language_manager[
+ Page.SEQUENCER,
+ Panel.VOICES,
+ TextType.TOOLTIP,
+ element,
+ ]
diff --git a/src/sampletones_application/ui/panels/shared/browser.py b/src/sampletones_application/ui/panels/shared/browser.py
index 35652a6be..6d7acceeb 100644
--- a/src/sampletones_application/ui/panels/shared/browser.py
+++ b/src/sampletones_application/ui/panels/shared/browser.py
@@ -33,7 +33,7 @@
class GUIReconstructionBrowserPanel(GUIFileBrowserPanel):
"""Shared skeleton of the reconstructions browser in the Sequencer and Reconstruction tabs.
- Reads the tree both tabs share into rows, colours the ones the browser invents, and routes node
+ Reads the tree both tabs share into rows, colors the ones the browser invents, and routes node
clicks to the subclass through :meth:`_open_reconstruction`. The subclass names its widgets and
its refresh control, and adds the items its context menus offer.
@@ -163,10 +163,10 @@ def _build_tree_node(
state.parent = node_tag
def _resolve_other_theme_tag(self, node: TreeNode) -> str:
- """Selects the colour of a row the browser invents: a plain group, or a sample in wave colour.
+ """Selects the color of a row the browser invents: a plain group, or a sample in wave color.
A sample row names the audio a set of reconstructions was made from, so it reads in the
- colour audio files carry elsewhere in the application.
+ color audio files carry elsewhere in the application.
"""
match node.node_type:
case NodeType.GROUP:
diff --git a/src/sampletones_application/ui/themes/channels.py b/src/sampletones_application/ui/themes/channels.py
index c37865540..2c2e94802 100644
--- a/src/sampletones_application/ui/themes/channels.py
+++ b/src/sampletones_application/ui/themes/channels.py
@@ -6,11 +6,11 @@
TAG_GLOBAL_THEME_CHANNEL_PULSE2,
TAG_GLOBAL_THEME_CHANNEL_TRIANGLE,
)
-from sampletones_core.constants.enums import GeneratorName
+from sampletones_core.constants.enums import ChannelName
-CHANNEL_THEME_TAGS: Final[Dict[GeneratorName, str]] = {
- GeneratorName.PULSE1: TAG_GLOBAL_THEME_CHANNEL_PULSE1,
- GeneratorName.PULSE2: TAG_GLOBAL_THEME_CHANNEL_PULSE2,
- GeneratorName.TRIANGLE: TAG_GLOBAL_THEME_CHANNEL_TRIANGLE,
- GeneratorName.NOISE: TAG_GLOBAL_THEME_CHANNEL_NOISE,
+CHANNEL_THEME_TAGS: Final[Dict[ChannelName, str]] = {
+ ChannelName.PULSE1: TAG_GLOBAL_THEME_CHANNEL_PULSE1,
+ ChannelName.PULSE2: TAG_GLOBAL_THEME_CHANNEL_PULSE2,
+ ChannelName.TRIANGLE: TAG_GLOBAL_THEME_CHANNEL_TRIANGLE,
+ ChannelName.NOISE: TAG_GLOBAL_THEME_CHANNEL_NOISE,
}
diff --git a/src/sampletones_application/ui/themes/dpg_constants.py b/src/sampletones_application/ui/themes/dpg_constants.py
index 5eed2b854..a9871b817 100644
--- a/src/sampletones_application/ui/themes/dpg_constants.py
+++ b/src/sampletones_application/ui/themes/dpg_constants.py
@@ -2,6 +2,8 @@
import dearpygui.dearpygui as dpg
+EVERY_ITEM_TYPE: Final[int] = dpg.mvAll
+
ITEM_TYPE_MAP: Final[Dict[str, int]] = {
"All": dpg.mvAll,
"Button": dpg.mvButton,
diff --git a/src/sampletones_application/ui/themes/inline.py b/src/sampletones_application/ui/themes/inline.py
index de518002c..14aa7b9c9 100644
--- a/src/sampletones_application/ui/themes/inline.py
+++ b/src/sampletones_application/ui/themes/inline.py
@@ -8,7 +8,7 @@
def create_selectable_text_theme(color: BaseColor) -> int:
- """Builds a theme colouring selectable text, leaving its other colours to the global theme."""
+ """Builds a theme coloring selectable text, leaving its other colors to the global theme."""
return _create_selectable_theme({dpg.mvThemeCol_Text: color})
@@ -19,8 +19,8 @@ def create_header_selectable_theme(
) -> int:
"""Builds a theme for a selectable that carries a table column's label.
- A selectable takes the ``Header`` colours under the pointer, so naming those alongside the
- text colour gives a header label the pointer feedback a table header has, in place of the
+ A selectable takes the ``Header`` colors under the pointer, so naming those alongside the
+ text color gives a header label the pointer feedback a table header has, in place of the
selection shade a cell takes.
"""
return _create_selectable_theme(
@@ -35,7 +35,7 @@ def create_header_selectable_theme(
def create_label_selectable_theme(color: BaseColor) -> int:
"""Builds a theme for a selectable that carries a label rather than a gesture.
- Every header wash takes the label's own colour at zero alpha, so the cell reads as plain
+ Every header wash takes the label's own color at zero alpha, so the cell reads as plain
text while it keeps the layout a selectable lays out with, which is what lets it line up
with the clickable labels beside it.
"""
@@ -54,7 +54,7 @@ def _create_selectable_theme(colors: Dict[int, BaseColor]) -> int:
"""Builds a theme carrying ``colors`` for a selectable in both enabled states.
DearPyGui resolves an item against the theme component that matches the
- item's enabled state. Carrying the colour in both components keeps the theme
+ item's enabled state. Carrying the color in both components keeps the theme
authoritative for the selectable in every state — including frames where its
container is disabled — matching the loader's policy that a theme fully
describes both item states.
diff --git a/src/sampletones_application/ui/themes/items.py b/src/sampletones_application/ui/themes/items.py
index fee56fa1f..f1bfff8b6 100644
--- a/src/sampletones_application/ui/themes/items.py
+++ b/src/sampletones_application/ui/themes/items.py
@@ -10,13 +10,13 @@
class ThemeEntryKey(NamedTuple):
- """Merge identity of one theme colour or style entry.
+ """Merge identity of one theme color or style entry.
Entries sharing a key target the same slot, so inheritance resolution lets a
child theme's entry override its parent's, and the disabled mirror can address
the disabled counterpart of an enabled slot directly. ``is_style`` separates the
- colour and style enum families, whose integer keys overlap, so a style and a
- colour sharing an item type and integer occupy distinct slots.
+ color and style enum families, whose integer keys overlap, so a style and a
+ color sharing an item type and integer occupy distinct slots.
"""
item_type: int
diff --git a/src/sampletones_application/ui/themes/loader.py b/src/sampletones_application/ui/themes/loader.py
index 61ed75738..5949fa7ab 100644
--- a/src/sampletones_application/ui/themes/loader.py
+++ b/src/sampletones_application/ui/themes/loader.py
@@ -5,6 +5,7 @@
CATEGORY_MAP,
CORE_COLOR_MAP,
CORE_STYLE_MAP,
+ EVERY_ITEM_TYPE,
ITEM_TYPE_MAP,
PLOTS_COLOR_MAP,
PLOTS_STYLE_MAP,
@@ -84,7 +85,7 @@ def _effective_parent(spec: ThemeSpec) -> Optional[str]:
the base theme, so every theme carries the shared base's complete component
set and states only its own overrides. The base theme itself stands alone.
This makes inheritance the default: a bound item theme always resolves to
- the global base plus its overrides, so every colour it omits keeps the
+ the global base plus its overrides, so every color it omits keeps the
base's value.
"""
if spec.extends is not None:
@@ -187,7 +188,7 @@ def _mirror_disabled_entries(entries: ThemeEntries) -> ThemeEntries:
item's enabled state, and it classifies many presentation items (menus,
text labels, tree headers) as disabled; when the matching component is
missing it re-applies its built-in palette, and the palette of the last
- themed item drawn bleeds into the global style, recolouring the entire
+ themed item drawn bleeds into the global style, recoloring the entire
application. Mirroring keeps every theme complete for both states, so items
render with the theme's own values whichever state DearPyGui assigns them
and the global style stays intact. Disabled-state entries stated explicitly
@@ -222,11 +223,21 @@ def _mirror_disabled_entries(entries: ThemeEntries) -> ThemeEntries:
@staticmethod
def _entries_to_items(entries: ThemeEntries) -> ThemeItems:
+ """Gathers the entries into the components a theme is built from, broadest first.
+
+ DearPyGui fills an item's colors by walking a theme's components in the order they were
+ created, so the last one covering a color is the one the item wears. A component naming a
+ single item type states what that type is meant to look like, and one naming every type
+ states the ground it stands on, so the ground is laid first and the item type paints over
+ it. Ordering them here keeps that true whichever order a theme and the theme it extends
+ happened to state them in, and whichever entries the disabled-state mirror added.
+ """
grouped: Dict[ThemeParameter, List[ThemeValue]] = {}
for parameter, value in entries.values():
grouped.setdefault(parameter, []).append(value)
- return ThemeItems(items=grouped)
+ ordered = sorted(grouped, key=lambda parameter: parameter.item_type != EVERY_ITEM_TYPE)
+ return ThemeItems(items={parameter: grouped[parameter] for parameter in ordered})
@classmethod
def _check_for_cycles(cls, name_index: Dict[str, ThemeSpec]) -> None:
diff --git a/src/sampletones_application/ui/themes/theme.py b/src/sampletones_application/ui/themes/theme.py
index 05db24721..b80cc0ff1 100644
--- a/src/sampletones_application/ui/themes/theme.py
+++ b/src/sampletones_application/ui/themes/theme.py
@@ -40,10 +40,19 @@ def _index(items: ThemeItems) -> ThemeDictionary:
return dictionary
+ @property
+ def components(self) -> Tuple[ThemeParameter, ...]:
+ """The components the theme is built from, in the order DearPyGui fills an item from them.
+
+ A later component covering a color is the one the item wears, so the order states which
+ of two components naming the same color has the final say.
+ """
+ return tuple(self._items.items)
+
def create(self) -> None:
- """Builds the DearPyGui theme once, registering each colour item it fills.
+ """Builds the DearPyGui theme once, registering each color item it fills.
- DearPyGui copies a colour into the item at the call that fills it, so each one is
+ DearPyGui copies a color into the item at the call that fills it, so each one is
handed over through the palette bindings, which repaint the theme in place when
another palette is activated.
"""
@@ -102,7 +111,7 @@ def get_color(
enabled_state: bool = True,
category: int = dpg.mvThemeCat_Core,
) -> Optional[ColorRGBA]:
- """The value a theme colour carries under the active palette."""
+ """The value a theme color carries under the active palette."""
theme_item = self.get(
item_type,
key,
diff --git a/src/sampletones_application/utils/file_dialogs/backends/portal/client.py b/src/sampletones_application/utils/file_dialogs/backends/portal/client.py
index ba7ea4b9a..60f3847b7 100644
--- a/src/sampletones_application/utils/file_dialogs/backends/portal/client.py
+++ b/src/sampletones_application/utils/file_dialogs/backends/portal/client.py
@@ -130,7 +130,7 @@ def call(
@staticmethod
def _response_rule() -> MatchRule:
- """Subscribes to the outcome of every portal request, each call recognising its own."""
+ """Subscribes to the outcome of every portal request, each call recognizing its own."""
return MatchRule(
type="signal",
interface=REQUEST_INTERFACE,
diff --git a/src/sampletones_application/utils/file_dialogs/result.py b/src/sampletones_application/utils/file_dialogs/result.py
index 590c4b217..36b32d9f5 100644
--- a/src/sampletones_application/utils/file_dialogs/result.py
+++ b/src/sampletones_application/utils/file_dialogs/result.py
@@ -43,11 +43,11 @@ def ignore_none_path(
Callable[Concatenate[T, Optional[Path], P], R],
],
]:
- """Wraps a path handler so a cancelled dialog resolves to ``default``.
+ """Wraps a path handler so a canceled dialog resolves to ``default``.
Applied bare (``@ignore_none_path``) the wrapped method runs with the selected ``Path`` and
- yields ``None`` when the dialog was cancelled. Applied with a ``default``
- (``@ignore_none_path(default=...)``) the cancelled case yields that value instead, so a handler
+ yields ``None`` when the dialog was canceled. Applied with a ``default``
+ (``@ignore_none_path(default=...)``) the canceled case yields that value instead, so a handler
that reports an outcome — such as a save returning whether it wrote — carries a truthful result
through the cancellation. Each handler body runs only with a real path.
"""
diff --git a/src/sampletones_application/utils/file_dialogs/selection.py b/src/sampletones_application/utils/file_dialogs/selection.py
index 2859c5ad9..c05f1d446 100644
--- a/src/sampletones_application/utils/file_dialogs/selection.py
+++ b/src/sampletones_application/utils/file_dialogs/selection.py
@@ -1,18 +1,16 @@
-import importlib.util
import os
-import shutil
from typing import Final, Optional
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.modules import JEEPNEY_MODULE, TKINTER_MODULE, module_available
+from sampletones_shared.utils.system.programs import locate_program
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"
@@ -58,8 +56,8 @@ def _select_linux_backend() -> Optional[FileDialogBackend]:
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
+ kdialog = KDialogBackend() if locate_program(KDIALOG) is not None else None
+ zenity = ZenityBackend() if locate_program(ZENITY) is not None else None
preferred: Optional[FileDialogBackend]
alternative: Optional[FileDialogBackend]
@@ -78,7 +76,7 @@ def _portal_backend() -> Optional[FileDialogBackend]:
``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:
+ if not module_available(JEEPNEY_MODULE):
return None
from sampletones_application.utils.file_dialogs.backends.portal.backend import portal_backend
@@ -94,7 +92,7 @@ def _tkinter_backend() -> Optional[FileDialogBackend]:
probed before the backend module is imported. Keeping the import inside this function leaves
application startup independent of Tk.
"""
- if importlib.util.find_spec(TKINTER_MODULE) is None:
+ if not module_available(TKINTER_MODULE):
return None
from sampletones_application.utils.file_dialogs.backends.tkinter import TkinterBackend
diff --git a/src/sampletones_application/utils/gui/align.py b/src/sampletones_application/utils/gui/align.py
index 4117f4949..5079b7376 100644
--- a/src/sampletones_application/utils/gui/align.py
+++ b/src/sampletones_application/utils/gui/align.py
@@ -23,11 +23,11 @@ def center_item(tag: str) -> None:
def center_when_settled(tag: str) -> None:
- """Centres an autosizing window once its content has reached its final size.
+ """Centers an autosizing window once its content has reached its final size.
An autosize window measures its content across the first couple of frames, so a stretch
table or wrapped text reaches its final width and height only on the second layout pass.
- Deferring the centre until then reads the settled size, so the window rests centred on its
+ Deferring the center until then reads the settled size, so the window rests centered on its
first appearance the same way a reopened one does from its remembered size.
"""
FrameCallbackManager.set_frame_callback(
diff --git a/src/sampletones_application/utils/gui/dialogs/__init__.py b/src/sampletones_application/utils/gui/dialogs/__init__.py
new file mode 100644
index 000000000..539ec4da2
--- /dev/null
+++ b/src/sampletones_application/utils/gui/dialogs/__init__.py
@@ -0,0 +1,6 @@
+from .renderer import DialogsRenderer, get_dialog_tag
+
+__all__ = [
+ "DialogsRenderer",
+ "get_dialog_tag",
+]
diff --git a/src/sampletones_application/utils/gui/dialogs.py b/src/sampletones_application/utils/gui/dialogs/renderer.py
similarity index 62%
rename from src/sampletones_application/utils/gui/dialogs.py
rename to src/sampletones_application/utils/gui/dialogs/renderer.py
index 9cc89a16f..84daefe61 100644
--- a/src/sampletones_application/utils/gui/dialogs.py
+++ b/src/sampletones_application/utils/gui/dialogs/renderer.py
@@ -9,11 +9,7 @@
from sampletones_application.layout.general import GeneralLayout
from sampletones_application.tags.compose import compose_tag
from sampletones_application.tags.general import (
- SUF_BUTTON_CANCEL,
SUF_BUTTON_OK,
- SUF_BUTTON_SAVE,
- SUF_BUTTON_SHOW_TRACEBACK,
- SUF_CHECKBOX,
SUF_DIALOG_INFO,
SUF_GROUP,
SUF_PATH,
@@ -30,17 +26,22 @@
from sampletones_application.ui.elements.fonts.registry import FontRegistry
from sampletones_application.ui.elements.path import GUIPathText
from sampletones_application.ui.elements.status import GUIStatusBar
-from sampletones_application.ui.elements.trace import GUITraceback
from sampletones_application.ui.themes.registry import ThemeRegistry
-from sampletones_application.utils.gui.align import center_when_settled, table_wrapper
+from sampletones_application.utils.gui.align import center_when_settled
from sampletones_application.utils.gui.dialog_navigation import (
DialogKeyboardNavigator,
FocusStop,
)
-from sampletones_application.utils.gui.dpg import (
- dpg_configure_item,
- dpg_delete_item,
+from sampletones_application.utils.gui.dialogs.windows.confirmation import (
+ GUIConfirmationWindow,
)
+from sampletones_application.utils.gui.dialogs.windows.error import (
+ GUIErrorDialogWindow,
+)
+from sampletones_application.utils.gui.dialogs.windows.save_confirmation import (
+ GUISaveConfirmationWindow,
+)
+from sampletones_application.utils.gui.dpg import dpg_delete_item
from sampletones_application.utils.gui.keyboard import KeyRouter
from sampletones_application.utils.gui.palette.dpg import dpg_set_palette_color
from sampletones_application.utils.gui.shortcuts.source import ShortcutSource
@@ -165,7 +166,7 @@ def __init__(
self._msg_path = language_manager["global.status.message.path"]
self._lbl_ok = language_manager["global.dialog.label.ok"]
self._lbl_cancel = language_manager["global.dialog.label.cancel"]
- self._lbl_traceback_show = language_manager["global.traceback.label.show"]
+ self._lbl_save = language_manager["global.dialog.label.save"]
def show_modal(
self,
@@ -226,8 +227,8 @@ def show_config_recovery(
"""
Reports that a stored configuration was migrated, listing the discarded settings.
- The version numbers are emphasised in bold, each discarded setting is drawn in the
- highlight colour to stand apart from the surrounding prose, and the trailing path
+ The version numbers are emphasized in bold, each discarded setting is drawn in the
+ highlight color to stand apart from the surrounding prose, and the trailing path
opens the configuration file's directory so the user can edit it directly.
"""
source = (
@@ -322,98 +323,21 @@ def _render_template_bold(
if trailing:
dpg.add_text(trailing, parent=group_tag)
- # TODO: refactor
def show_error(
self,
exception: Exception,
message: Optional[str] = None,
) -> None:
- tag = get_dialog_tag(TAG_GLOBAL_DIALOG_ERROR)
- show_button_tag = compose_tag(tag, SUF_BUTTON_SHOW_TRACEBACK)
- ok_button_tag = compose_tag(tag, SUF_BUTTON_OK)
- navigator: Optional[DialogKeyboardNavigator] = None
-
- def close() -> None:
- if navigator is not None:
- navigator.dispose()
-
- dpg_delete_item(tag)
-
- with dpg.window(
- label=self._language_manager["global.dialog.title.error"],
- tag=tag,
- modal=True,
- min_size=(self._error_width, self._error_height),
- autosize=True,
- no_scrollbar=False,
- on_close=close,
- ):
- _bind_dialog_theme(tag)
- if message is not None:
- dpg.add_text(message, parent=tag, wrap=self._error_wrap)
-
- group_tag = compose_tag(tag, SUF_GROUP)
- with dpg.group(tag=group_tag, parent=tag):
- name_text = dpg.add_text(
- f"{type(exception).__name__!s}: ",
- parent=group_tag,
- )
- dpg_set_palette_color(name_text, self._col_text_error)
- message_text = dpg.add_text(
- str(exception),
- parent=group_tag,
- wrap=self._error_wrap,
- )
- dpg_set_palette_color(message_text, self._col_text_error)
-
- traceback = GUITraceback(
- parent=tag,
- exception=exception,
- language_manager=self._language_manager,
- )
-
- dpg.add_separator()
-
- def toggle_traceback() -> None:
- traceback.toggle_visibility()
- dpg_configure_item(
- show_button_tag,
- label=(
- self._lbl_traceback_show
- if not traceback.visible
- else self._language_manager["global.traceback.label.hide"]
- ),
- )
-
- @table_wrapper(columns=2)
- def content(_: None) -> None:
- GUIButton(
- tag=show_button_tag,
- label=self._lbl_traceback_show,
- width=-1,
- callback=toggle_traceback,
- )
- GUIButton(
- tag=ok_button_tag,
- label=self._lbl_ok,
- callback=close,
- width=-1,
- )
-
- content(None)
-
- navigator = _install_navigation(
- window_tag=tag,
- stops=[
- FocusStop.button(show_button_tag, toggle_traceback),
- FocusStop.button(ok_button_tag, close),
- ],
- on_escape=close,
+ GUIErrorDialogWindow(
+ tag=get_dialog_tag(TAG_GLOBAL_DIALOG_ERROR),
+ width=self._error_width,
+ height=self._error_height,
+ wrap=self._error_wrap,
+ language_manager=self._language_manager,
+ error_color=self._col_text_error,
key_router=self._router,
shortcut_source=self._shortcuts,
- initial_index=1,
- )
- center_when_settled(tag)
+ ).show(exception, message)
def show_file_not_found(self, filepath: Path, message: str) -> None:
tag = get_dialog_tag(TAG_GLOBAL_DIALOG_FILE_NOT_FOUND)
@@ -438,7 +362,6 @@ def content(parent: str) -> None:
height=self._default_height,
)
- # TODO: refactor
def show_confirmation(
self,
tag: str,
@@ -462,102 +385,29 @@ def show_confirmation(
When ``opt_out_label`` is given, a checkbox is shown; if it is ticked when the user
confirms, ``on_opt_out`` runs as well — letting the caller suppress future prompts.
"""
- tag = get_dialog_tag(tag)
- opt_out_tag = compose_tag(tag, SUF_CHECKBOX)
- cancel_label = cancel_label if cancel_label is not None else self._lbl_cancel
- ok_button_tag = compose_tag(tag, SUF_BUTTON_OK)
- cancel_button_tag = compose_tag(tag, SUF_BUTTON_CANCEL)
- navigator: Optional[DialogKeyboardNavigator] = None
-
- def disable() -> None:
- dpg_configure_item(ok_button_tag, enabled=False)
- dpg_configure_item(cancel_button_tag, enabled=False)
-
- def close() -> None:
- if navigator is not None:
- navigator.dispose()
-
- dpg_delete_item(tag)
-
- def _on_confirm() -> None:
- disable()
- if opt_out_label is not None and on_opt_out is not None and dpg.get_value(opt_out_tag):
- on_opt_out()
-
- on_confirm()
- close()
-
- def _on_cancel() -> None:
- disable()
- if on_cancel is not None:
- on_cancel()
-
- close()
-
- def content(parent: str) -> None:
- dpg.add_text(message, parent=parent, wrap=self._default_wrap)
-
- if path is not None:
- GUIPathText(
- tag=compose_tag(tag, SUF_PATH),
- path=path,
- parent=parent,
- color=self._col_path,
- hover_color=self._col_path_hover,
- status_message=self._msg_path,
- use_filename_only=True,
- status_bar=self._status_bar,
- )
-
- if opt_out_label is not None:
- dpg.add_checkbox(
- label=opt_out_label,
- tag=opt_out_tag,
- parent=parent,
- )
-
- @table_wrapper(columns=2)
- def buttons(_: None) -> None:
- GUIButton(
- tag=ok_button_tag,
- label=ok_label,
- callback=_on_confirm,
- width=-1,
- )
- GUIButton(
- tag=cancel_button_tag,
- label=cancel_label,
- callback=_on_cancel,
- width=-1,
- )
-
- buttons(None)
-
- with dpg.window(
- label=title,
- tag=tag,
- modal=True,
- min_size=(self._default_width, self._confirmation_height),
- no_resize=True,
- on_close=_on_cancel,
- ):
- _bind_dialog_theme(tag)
- content(tag)
-
- navigator = _install_navigation(
- window_tag=tag,
- stops=[
- FocusStop.button(ok_button_tag, _on_confirm),
- FocusStop.button(cancel_button_tag, _on_cancel),
- ],
- on_escape=_on_cancel,
+ GUIConfirmationWindow(
+ tag=get_dialog_tag(tag),
+ width=self._default_width,
+ height=self._confirmation_height,
+ wrap=self._default_wrap,
+ path_color=self._col_path,
+ path_hover_color=self._col_path_hover,
+ path_message=self._msg_path,
+ status_bar=self._status_bar,
key_router=self._router,
shortcut_source=self._shortcuts,
- initial_index=1,
+ ).show(
+ message,
+ title,
+ on_confirm,
+ ok_label=ok_label,
+ cancel_label=cancel_label if cancel_label is not None else self._lbl_cancel,
+ path=path,
+ opt_out_label=opt_out_label,
+ on_opt_out=on_opt_out,
+ on_cancel=on_cancel,
)
- center_when_settled(tag)
- # TODO: refactor
def show_save_confirmation(
self,
tag: str,
@@ -571,94 +421,26 @@ def show_save_confirmation(
"""Modal save-or-proceed prompt for an unsaved document.
``on_save`` writes the document and reports whether it completed; the prompt runs
- ``on_confirm`` and closes once the save reports success, so a cancelled save keeps the
+ ``on_confirm`` and closes once the save reports success, so a canceled save keeps the
prompt open for another attempt. The middle button discards the pending changes and runs
``on_confirm`` to proceed, and Cancel — the initially focused button — dismisses the prompt.
"""
- tag = get_dialog_tag(tag)
- save_button_tag = compose_tag(tag, SUF_BUTTON_SAVE)
- ok_button_tag = compose_tag(tag, SUF_BUTTON_OK)
- cancel_button_tag = compose_tag(tag, SUF_BUTTON_CANCEL)
- navigator: Optional[DialogKeyboardNavigator] = None
-
- def disable() -> None:
- dpg_configure_item(save_button_tag, enabled=False)
- dpg_configure_item(ok_button_tag, enabled=False)
- dpg_configure_item(cancel_button_tag, enabled=False)
-
- def close() -> None:
- if navigator is not None:
- navigator.dispose()
-
- dpg_delete_item(tag)
-
- def _on_save() -> None:
- if not on_save():
- return
-
- disable()
- on_confirm()
- close()
-
- def _on_confirm() -> None:
- disable()
- on_confirm()
- close()
-
- def _on_cancel() -> None:
- disable()
- close()
-
- def content(parent: str) -> None:
- dpg.add_text(message, parent=parent, wrap=self._default_wrap)
-
- @table_wrapper(columns=3)
- def buttons(_: None) -> None:
- GUIButton(
- tag=save_button_tag,
- label=self._language_manager["global.dialog.label.save"],
- callback=_on_save,
- width=-1,
- )
- GUIButton(
- tag=ok_button_tag,
- label=ok_label,
- callback=_on_confirm,
- width=-1,
- )
- GUIButton(
- tag=cancel_button_tag,
- label=self._lbl_cancel,
- callback=_on_cancel,
- width=-1,
- )
-
- buttons(None)
-
- with dpg.window(
- label=title,
- tag=tag,
- modal=True,
- min_size=(self._default_width, self._confirmation_height),
- no_resize=True,
- on_close=close,
- ):
- _bind_dialog_theme(tag)
- content(tag)
-
- navigator = _install_navigation(
- window_tag=tag,
- stops=[
- FocusStop.button(save_button_tag, _on_save),
- FocusStop.button(ok_button_tag, _on_confirm),
- FocusStop.button(cancel_button_tag, _on_cancel),
- ],
- on_escape=_on_cancel,
+ GUISaveConfirmationWindow(
+ tag=get_dialog_tag(tag),
+ width=self._default_width,
+ height=self._confirmation_height,
+ wrap=self._default_wrap,
+ save_label=self._lbl_save,
+ cancel_label=self._lbl_cancel,
key_router=self._router,
shortcut_source=self._shortcuts,
- initial_index=2,
+ ).show(
+ message,
+ title,
+ on_save,
+ on_confirm,
+ ok_label=ok_label,
)
- center_when_settled(tag)
def show_reconstruction_not_loaded(self) -> None:
tag = get_dialog_tag(TAG_RECONSTRUCTIONS_RECONSTRUCTION_DIALOG_NOT_LOADED)
diff --git a/src/sampletones_application/utils/gui/dialogs/windows/__init__.py b/src/sampletones_application/utils/gui/dialogs/windows/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/src/sampletones_application/utils/gui/dialogs/windows/confirmation.py b/src/sampletones_application/utils/gui/dialogs/windows/confirmation.py
new file mode 100644
index 000000000..cbdb37d6b
--- /dev/null
+++ b/src/sampletones_application/utils/gui/dialogs/windows/confirmation.py
@@ -0,0 +1,175 @@
+from pathlib import Path
+from typing import Final, Optional
+
+import dearpygui.dearpygui as dpg
+
+from sampletones_application.tags.compose import compose_tag
+from sampletones_application.tags.general import (
+ SUF_BUTTON_CANCEL,
+ SUF_BUTTON_OK,
+ SUF_CHECKBOX,
+ SUF_PATH,
+)
+from sampletones_application.ui.elements.button import GUIButton
+from sampletones_application.ui.elements.dialog import GUIDialogWindow
+from sampletones_application.ui.elements.path import GUIPathText
+from sampletones_application.ui.elements.status import GUIStatusBar
+from sampletones_application.utils.gui.align import table_wrapper
+from sampletones_application.utils.gui.dialog_navigation import FocusStop
+from sampletones_application.utils.gui.dpg import dpg_configure_item
+from sampletones_application.utils.gui.keyboard import KeyRouter
+from sampletones_application.utils.gui.shortcuts.source import ShortcutSource
+from sampletones_application.utils.palette.colors.base import BaseColor
+from sampletones_shared.types.callback import Callback
+
+CANCEL_FOCUS_STOP: Final[int] = 1
+
+
+class GUIConfirmationWindow(GUIDialogWindow):
+ """A modal asking one question, answered through OK, Cancel, or the title-bar close.
+
+ ``on_confirm``/``on_cancel`` run on the respective choice, and the title bar reads as
+ the negative one, so every way out of the prompt reaches the caller. A checked opt-out
+ checkbox adds ``on_opt_out`` to a confirmation, letting the caller suppress future
+ prompts. Cancel — the initially focused button — keeps the prompt answerable by
+ keyboard alone.
+ """
+
+ _fits_content = True
+
+ def __init__(
+ self,
+ tag: str,
+ *,
+ width: int,
+ height: int,
+ wrap: int,
+ path_color: BaseColor,
+ path_hover_color: BaseColor,
+ path_message: str,
+ status_bar: GUIStatusBar,
+ key_router: KeyRouter,
+ shortcut_source: ShortcutSource,
+ ) -> None:
+ self._wrap = wrap
+ self._path_color = path_color
+ self._path_hover_color = path_hover_color
+ self._path_message = path_message
+ self._status_bar = status_bar
+
+ self._message: str
+ self._title: str
+ self._on_confirm: Callback
+ self._on_cancel: Optional[Callback]
+ self._on_opt_out: Optional[Callback]
+ self._ok_label: str
+ self._cancel_label: str
+ self._path: Optional[Path]
+ self._opt_out_label: Optional[str]
+
+ super().__init__(
+ tag,
+ width,
+ height,
+ key_router=key_router,
+ shortcut_source=shortcut_source,
+ )
+
+ def prepare(
+ self,
+ message: str,
+ title: str,
+ on_confirm: Callback,
+ *,
+ ok_label: str,
+ cancel_label: str,
+ path: Optional[Path],
+ opt_out_label: Optional[str],
+ on_opt_out: Optional[Callback],
+ on_cancel: Optional[Callback],
+ ) -> None:
+ """Captures the question and its answers for the next appearance."""
+ self._message = message
+ self._title = title
+ self._on_confirm = on_confirm
+ self._on_cancel = on_cancel
+ self._on_opt_out = on_opt_out
+ self._ok_label = ok_label
+ self._cancel_label = cancel_label
+ self._path = path
+ self._opt_out_label = opt_out_label
+
+ def create_window(self) -> None:
+ opt_out_tag = compose_tag(self.tag, SUF_CHECKBOX)
+ ok_button_tag = compose_tag(self.tag, SUF_BUTTON_OK)
+ cancel_button_tag = compose_tag(self.tag, SUF_BUTTON_CANCEL)
+
+ def disable() -> None:
+ dpg_configure_item(ok_button_tag, enabled=False)
+ dpg_configure_item(cancel_button_tag, enabled=False)
+
+ def _on_confirm() -> None:
+ disable()
+ if self._opt_out_label is not None and self._on_opt_out is not None and dpg.get_value(opt_out_tag):
+ self._on_opt_out()
+
+ self._on_confirm()
+ self.hide()
+
+ def _on_cancel() -> None:
+ disable()
+ if self._on_cancel is not None:
+ self._on_cancel()
+
+ self.hide()
+
+ def content(parent: str) -> None:
+ dpg.add_text(self._message, parent=parent, wrap=self._wrap)
+
+ if self._path is not None:
+ GUIPathText(
+ tag=compose_tag(self.tag, SUF_PATH),
+ path=self._path,
+ parent=parent,
+ color=self._path_color,
+ hover_color=self._path_hover_color,
+ status_message=self._path_message,
+ use_filename_only=True,
+ status_bar=self._status_bar,
+ )
+
+ if self._opt_out_label is not None:
+ dpg.add_checkbox(
+ label=self._opt_out_label,
+ tag=opt_out_tag,
+ parent=parent,
+ )
+
+ @table_wrapper(columns=2)
+ def buttons(_: None) -> None:
+ GUIButton(
+ tag=ok_button_tag,
+ label=self._ok_label,
+ callback=_on_confirm,
+ width=-1,
+ )
+ GUIButton(
+ tag=cancel_button_tag,
+ label=self._cancel_label,
+ callback=_on_cancel,
+ width=-1,
+ )
+
+ buttons(None)
+
+ with self.dialog_window(label=self._title, on_close=_on_cancel):
+ content(self.tag)
+
+ self._install_navigation(
+ [
+ FocusStop.button(ok_button_tag, _on_confirm),
+ FocusStop.button(cancel_button_tag, _on_cancel),
+ ],
+ on_escape=_on_cancel,
+ initial_index=CANCEL_FOCUS_STOP,
+ )
diff --git a/src/sampletones_application/utils/gui/dialogs/windows/error.py b/src/sampletones_application/utils/gui/dialogs/windows/error.py
new file mode 100644
index 000000000..3e7d63fc0
--- /dev/null
+++ b/src/sampletones_application/utils/gui/dialogs/windows/error.py
@@ -0,0 +1,139 @@
+from typing import Final, Optional
+
+import dearpygui.dearpygui as dpg
+
+from sampletones_application.categories.manager import LanguageManager
+from sampletones_application.tags.compose import compose_tag
+from sampletones_application.tags.general import (
+ SUF_BUTTON_OK,
+ SUF_BUTTON_SHOW_TRACEBACK,
+ SUF_GROUP,
+)
+from sampletones_application.ui.elements.button import GUIButton
+from sampletones_application.ui.elements.dialog import GUIDialogWindow
+from sampletones_application.ui.elements.trace import GUITraceback
+from sampletones_application.utils.gui.align import table_wrapper
+from sampletones_application.utils.gui.dialog_navigation import FocusStop
+from sampletones_application.utils.gui.dpg import dpg_configure_item
+from sampletones_application.utils.gui.keyboard import KeyRouter
+from sampletones_application.utils.gui.palette.dpg import dpg_set_palette_color
+from sampletones_application.utils.gui.shortcuts.source import ShortcutSource
+from sampletones_application.utils.palette.colors.base import BaseColor
+
+OK_FOCUS_STOP: Final[int] = 1
+
+
+class GUIErrorDialogWindow(GUIDialogWindow):
+ """A modal reporting an exception with its message and an optional traceback.
+
+ The exception's name and text are drawn in the error color, the traceback starts
+ hidden behind its toggle, and OK — the initially focused button — dismisses the
+ prompt. The title-bar close reads the same way.
+ """
+
+ def __init__(
+ self,
+ tag: str,
+ *,
+ width: int,
+ height: int,
+ wrap: int,
+ language_manager: LanguageManager,
+ error_color: BaseColor,
+ key_router: KeyRouter,
+ shortcut_source: ShortcutSource,
+ ) -> None:
+ self._language_manager = language_manager
+ self._wrap = wrap
+ self._error_color = error_color
+ self._exception: Exception
+ self._message: Optional[str]
+
+ super().__init__(
+ tag,
+ width,
+ height,
+ key_router=key_router,
+ shortcut_source=shortcut_source,
+ )
+
+ def prepare(self, exception: Exception, message: Optional[str]) -> None:
+ """Captures the failure the next appearance reports."""
+ self._exception = exception
+ self._message = message
+
+ def create_window(self) -> None:
+ show_button_tag = compose_tag(self.tag, SUF_BUTTON_SHOW_TRACEBACK)
+ ok_button_tag = compose_tag(self.tag, SUF_BUTTON_OK)
+ traceback: GUITraceback
+
+ with dpg.window(
+ tag=self.tag,
+ label=self._language_manager["global.dialog.title.error"],
+ modal=True,
+ min_size=(self.width, self.height),
+ autosize=True,
+ no_scrollbar=False,
+ on_close=self.hide,
+ ):
+ if self._message is not None:
+ dpg.add_text(self._message, parent=self.tag, wrap=self._wrap)
+
+ group_tag = compose_tag(self.tag, SUF_GROUP)
+ with dpg.group(tag=group_tag, parent=self.tag):
+ name_text = dpg.add_text(
+ f"{type(self._exception).__name__!s}: ",
+ parent=group_tag,
+ )
+ dpg_set_palette_color(name_text, self._error_color)
+ message_text = dpg.add_text(
+ str(self._exception),
+ parent=group_tag,
+ wrap=self._wrap,
+ )
+ dpg_set_palette_color(message_text, self._error_color)
+
+ traceback = GUITraceback(
+ parent=self.tag,
+ exception=self._exception,
+ language_manager=self._language_manager,
+ )
+
+ def toggle_traceback() -> None:
+ traceback.toggle_visibility()
+ dpg_configure_item(
+ show_button_tag,
+ label=(
+ self._language_manager["global.traceback.label.show"]
+ if not traceback.visible
+ else self._language_manager["global.traceback.label.hide"]
+ ),
+ )
+
+ dpg.add_separator()
+
+ @table_wrapper(columns=2)
+ def buttons(_: None) -> None:
+ GUIButton(
+ tag=show_button_tag,
+ label=self._language_manager["global.traceback.label.show"],
+ width=-1,
+ callback=toggle_traceback,
+ )
+ GUIButton(
+ tag=ok_button_tag,
+ label=self._language_manager["global.dialog.label.ok"],
+ callback=self.hide,
+ width=-1,
+ )
+
+ buttons(None)
+
+ self._install_navigation(
+ [
+ FocusStop.button(show_button_tag, toggle_traceback),
+ FocusStop.button(ok_button_tag, self.hide),
+ ],
+ on_escape=self.hide,
+ initial_index=OK_FOCUS_STOP,
+ )
diff --git a/src/sampletones_application/utils/gui/dialogs/windows/save_confirmation.py b/src/sampletones_application/utils/gui/dialogs/windows/save_confirmation.py
new file mode 100644
index 000000000..f100c6e3c
--- /dev/null
+++ b/src/sampletones_application/utils/gui/dialogs/windows/save_confirmation.py
@@ -0,0 +1,145 @@
+from typing import Callable, Final
+
+import dearpygui.dearpygui as dpg
+
+from sampletones_application.tags.compose import compose_tag
+from sampletones_application.tags.general import (
+ SUF_BUTTON_CANCEL,
+ SUF_BUTTON_OK,
+ SUF_BUTTON_SAVE,
+)
+from sampletones_application.ui.elements.button import GUIButton
+from sampletones_application.ui.elements.dialog import GUIDialogWindow
+from sampletones_application.utils.gui.align import table_wrapper
+from sampletones_application.utils.gui.dialog_navigation import FocusStop
+from sampletones_application.utils.gui.dpg import dpg_configure_item
+from sampletones_application.utils.gui.keyboard import KeyRouter
+from sampletones_application.utils.gui.shortcuts.source import ShortcutSource
+from sampletones_shared.types.callback import Callback
+
+CANCEL_FOCUS_STOP: Final[int] = 2
+
+
+class GUISaveConfirmationWindow(GUIDialogWindow):
+ """A modal save-or-proceed prompt for an unsaved document.
+
+ ``on_save`` writes the document and reports whether it completed; the prompt runs
+ ``on_confirm`` and closes once the save reports success, so a canceled save keeps the
+ prompt open for another attempt. The middle button discards the pending changes and
+ runs ``on_confirm`` to proceed, and Cancel — the initially focused button — dismisses
+ the prompt.
+ """
+
+ _fits_content = True
+
+ def __init__(
+ self,
+ tag: str,
+ *,
+ width: int,
+ height: int,
+ wrap: int,
+ save_label: str,
+ cancel_label: str,
+ key_router: KeyRouter,
+ shortcut_source: ShortcutSource,
+ ) -> None:
+ self._wrap = wrap
+ self._save_label = save_label
+ self._cancel_label = cancel_label
+
+ self._message: str
+ self._title: str
+ self._on_save: Callable[[], bool]
+ self._on_confirm: Callback
+ self._ok_label: str
+
+ super().__init__(
+ tag,
+ width,
+ height,
+ key_router=key_router,
+ shortcut_source=shortcut_source,
+ )
+
+ def prepare(
+ self,
+ message: str,
+ title: str,
+ on_save: Callable[[], bool],
+ on_confirm: Callback,
+ *,
+ ok_label: str,
+ ) -> None:
+ """Captures the pending document's write and the two ways forward."""
+ self._message = message
+ self._title = title
+ self._on_save = on_save
+ self._on_confirm = on_confirm
+ self._ok_label = ok_label
+
+ def create_window(self) -> None:
+ save_button_tag = compose_tag(self.tag, SUF_BUTTON_SAVE)
+ ok_button_tag = compose_tag(self.tag, SUF_BUTTON_OK)
+ cancel_button_tag = compose_tag(self.tag, SUF_BUTTON_CANCEL)
+
+ def disable() -> None:
+ dpg_configure_item(save_button_tag, enabled=False)
+ dpg_configure_item(ok_button_tag, enabled=False)
+ dpg_configure_item(cancel_button_tag, enabled=False)
+
+ def _on_save() -> None:
+ if not self._on_save():
+ return
+
+ disable()
+ self._on_confirm()
+ self.hide()
+
+ def _on_confirm() -> None:
+ disable()
+ self._on_confirm()
+ self.hide()
+
+ def _on_cancel() -> None:
+ disable()
+ self.hide()
+
+ def content(parent: str) -> None:
+ dpg.add_text(self._message, parent=parent, wrap=self._wrap)
+
+ @table_wrapper(columns=3)
+ def buttons(_: None) -> None:
+ GUIButton(
+ tag=save_button_tag,
+ label=self._save_label,
+ callback=_on_save,
+ width=-1,
+ )
+ GUIButton(
+ tag=ok_button_tag,
+ label=self._ok_label,
+ callback=_on_confirm,
+ width=-1,
+ )
+ GUIButton(
+ tag=cancel_button_tag,
+ label=self._cancel_label,
+ callback=_on_cancel,
+ width=-1,
+ )
+
+ buttons(None)
+
+ with self.dialog_window(label=self._title, on_close=_on_cancel):
+ content(self.tag)
+
+ self._install_navigation(
+ [
+ FocusStop.button(save_button_tag, _on_save),
+ FocusStop.button(ok_button_tag, _on_confirm),
+ FocusStop.button(cancel_button_tag, _on_cancel),
+ ],
+ on_escape=_on_cancel,
+ initial_index=CANCEL_FOCUS_STOP,
+ )
diff --git a/src/sampletones_application/utils/gui/dpg.py b/src/sampletones_application/utils/gui/dpg.py
index 4f1c9a31b..b8d467ebc 100644
--- a/src/sampletones_application/utils/gui/dpg.py
+++ b/src/sampletones_application/utils/gui/dpg.py
@@ -124,7 +124,7 @@ def dpg_get_item_parent(
"""
try:
parent: Optional[Sender] = dpg.get_item_parent(tag, *args, **kwargs)
- except Exception: # TODO: unsafe broad exception
+ except Exception: # unsafe broad exception
return None
return parent
@@ -193,3 +193,57 @@ def dpg_is_item_hovered(
) -> Optional[bool]:
is_hovered: Optional[bool] = dpg.is_item_hovered(tag, *args, **kwargs)
return is_hovered
+
+
+def dpg_window_origin(
+ window: Sender,
+ anchor: Sender,
+) -> Tuple[float, float]:
+ """Where a child window's corner was drawn, in the coordinates a pointer is reported in.
+
+ A child window states where it sits within the container around it, while a widget states
+ where it was drawn on screen. A widget laid out in that same container therefore carries the
+ container's own origin, and the two readings of it together place the window on screen.
+
+ Args:
+ window: The child window whose corner is asked for.
+ anchor: A widget laid out in the same container as the window.
+
+ Returns:
+ Tuple[float, float]: The window's left and top edges on screen.
+ """
+ anchor_left, anchor_top = dpg.get_item_rect_min(anchor)
+ placed_left, placed_top = dpg.get_item_pos(anchor)
+ window_left, window_top = dpg.get_item_pos(window)
+ return (
+ float(anchor_left - placed_left + window_left),
+ float(anchor_top - placed_top + window_top),
+ )
+
+
+def dpg_pointer_within_window(
+ window: Sender,
+ anchor: Sender,
+) -> bool:
+ """Whether the pointer stands within a child window, measured against a widget beside it.
+
+ A scrolling table carries a window of its own, which takes the hover from the child window
+ holding it, so the rectangle answers where a hover state stays silent.
+
+ Args:
+ window: The child window the pointer is measured against.
+ anchor: A widget laid out in the same container as the window.
+
+ Returns:
+ bool: Whether the pointer stands within the window, which is False while either item
+ is yet to be built.
+ """
+ if not dpg.does_item_exist(window) or not dpg.does_item_exist(anchor):
+ return False
+
+ left, top = dpg_window_origin(window, anchor)
+ width, height = dpg.get_item_rect_size(window)
+ pointer_left, pointer_top = dpg.get_mouse_pos(local=False)
+ within_width: bool = left <= pointer_left <= left + width
+ within_height: bool = top <= pointer_top <= top + height
+ return within_width and within_height
diff --git a/src/sampletones_application/utils/gui/keyboard/capture.py b/src/sampletones_application/utils/gui/keyboard/capture.py
index 1bbb001ef..804c681f1 100644
--- a/src/sampletones_application/utils/gui/keyboard/capture.py
+++ b/src/sampletones_application/utils/gui/keyboard/capture.py
@@ -38,7 +38,7 @@ def __init__(
self._listening = False
self.on_captured: Optional[Callback] = None
- self.on_cancelled: Optional[VoidCallback] = None
+ self.on_canceled: Optional[VoidCallback] = None
@property
def is_listening(self) -> bool:
@@ -69,7 +69,7 @@ def handle_key(self, event: KeyEvent) -> None:
combination = KeyCombination(event.key, event.modifiers)
self.stop()
if combination in self._cancel:
- self.call(self.on_cancelled)
+ self.call(self.on_canceled)
return
self.call(self.on_captured, combination)
diff --git a/src/sampletones_application/utils/gui/keyboard/modifiers.py b/src/sampletones_application/utils/gui/keyboard/modifiers.py
index 0cab9693b..7110d56a8 100644
--- a/src/sampletones_application/utils/gui/keyboard/modifiers.py
+++ b/src/sampletones_application/utils/gui/keyboard/modifiers.py
@@ -101,7 +101,7 @@ def modifier_display(modifier: Modifier) -> str:
"""The name a modifier reads under on the platform in use.
One key wears three names across the platforms — Command on macOS, Windows on Windows, Super on
- Linux — so a combination reads the way the keyboard in front of the reader is labelled. Every
+ Linux — so a combination reads the way the keyboard in front of the reader is labeled. Every
spelling stays readable everywhere through :data:`MODIFIER_NAMES`, which lets a scheme written
for one platform be read on another.
"""
diff --git a/src/sampletones_application/utils/gui/keyboard/piano.py b/src/sampletones_application/utils/gui/keyboard/piano.py
new file mode 100644
index 000000000..8b5afc979
--- /dev/null
+++ b/src/sampletones_application/utils/gui/keyboard/piano.py
@@ -0,0 +1,46 @@
+from typing import Dict, Final, Tuple
+
+import dearpygui.dearpygui as dpg
+
+from sampletones_shared.constants.music import OCTAVE_SEMITONES
+
+_LOWER_ROW: Final[Tuple[int, ...]] = (
+ dpg.mvKey_Z,
+ dpg.mvKey_S,
+ dpg.mvKey_X,
+ dpg.mvKey_D,
+ dpg.mvKey_C,
+ dpg.mvKey_V,
+ dpg.mvKey_G,
+ dpg.mvKey_B,
+ dpg.mvKey_H,
+ dpg.mvKey_N,
+ dpg.mvKey_J,
+ dpg.mvKey_M,
+)
+
+_UPPER_ROW: Final[Tuple[int, ...]] = (
+ dpg.mvKey_Q,
+ dpg.mvKey_2,
+ dpg.mvKey_W,
+ dpg.mvKey_3,
+ dpg.mvKey_E,
+ dpg.mvKey_R,
+ dpg.mvKey_5,
+ dpg.mvKey_T,
+ dpg.mvKey_6,
+ dpg.mvKey_Y,
+ dpg.mvKey_7,
+ dpg.mvKey_U,
+)
+
+PIANO_KEYS: Final[Dict[int, int]] = {
+ **{key: semitone for semitone, key in enumerate(_LOWER_ROW)},
+ **{key: OCTAVE_SEMITONES + semitone for semitone, key in enumerate(_UPPER_ROW)},
+}
+"""Each note key, as the semitones it stands above the C of the octave being typed at.
+
+Two rows of the keyboard make two octaves of a piano, the way a tracker lays them out: the
+bottom row opens at the octave in force and the top row an octave above it, with the black keys
+on the row over each.
+"""
diff --git a/src/sampletones_application/utils/gui/palette/binding.py b/src/sampletones_application/utils/gui/palette/binding.py
index 30c99e1ae..5ef5f4b16 100644
--- a/src/sampletones_application/utils/gui/palette/binding.py
+++ b/src/sampletones_application/utils/gui/palette/binding.py
@@ -13,7 +13,7 @@
@dataclass(frozen=True)
class ArgumentBinding:
- """A colour DearPyGui copied into one of an item's arguments."""
+ """A color DearPyGui copied into one of an item's arguments."""
item: Sender
color: BaseColor
@@ -25,7 +25,7 @@ def push(self) -> None:
@dataclass(frozen=True)
class ThemeColorBinding:
- """A colour DearPyGui copied into a theme colour item."""
+ """A color DearPyGui copied into a theme color item."""
item: Sender
color: BaseColor
diff --git a/src/sampletones_application/utils/gui/palette/dpg.py b/src/sampletones_application/utils/gui/palette/dpg.py
index 6adbcf422..c1f22aa35 100644
--- a/src/sampletones_application/utils/gui/palette/dpg.py
+++ b/src/sampletones_application/utils/gui/palette/dpg.py
@@ -12,12 +12,12 @@ def dpg_set_palette_color(
*,
argument: str = COLOR_ARGUMENT,
) -> None:
- """Colours an item so it follows the palette, in place of passing ``color=`` to DearPyGui.
+ """Colors an item so it follows the palette, in place of passing ``color=`` to DearPyGui.
Args:
- item: Item to colour.
- color: Token the colour is read from, kept for the next palette in place.
- argument: Name of the item's colour argument, for an item that carries more than one.
+ item: Item to color.
+ color: Token the color is read from, kept for the next palette in place.
+ argument: Name of the item's color argument, for an item that carries more than one.
"""
PaletteBindings.bind(
item,
@@ -32,15 +32,15 @@ def dpg_add_palette_theme_color(
*,
category: int = dpg.mvThemeCat_Core,
) -> Sender:
- """Adds a theme colour that follows the palette, inside an open theme component.
+ """Adds a theme color that follows the palette, inside an open theme component.
Args:
- key: Theme colour constant the value fills, such as ``dpg.mvThemeCol_Text``.
- color: Token the colour is read from, kept for the next palette in place.
+ key: Theme color constant the value fills, such as ``dpg.mvThemeCol_Text``.
+ color: Token the color is read from, kept for the next palette in place.
category: Theme category the constant belongs to.
Returns:
- Sender: The theme colour item, which repaints every widget bound to the theme.
+ Sender: The theme color item, which repaints every widget bound to the theme.
"""
item: Sender = dpg.add_theme_color(
key,
diff --git a/src/sampletones_application/utils/gui/palette/palette.py b/src/sampletones_application/utils/gui/palette/palette.py
index e060e4ac3..68a76d95d 100644
--- a/src/sampletones_application/utils/gui/palette/palette.py
+++ b/src/sampletones_application/utils/gui/palette/palette.py
@@ -15,15 +15,15 @@
class PaletteBindings:
- """Every colour DearPyGui holds a copy of, and the token each copy came from.
+ """Every color DearPyGui holds a copy of, and the token each copy came from.
- DearPyGui reads an item's colour argument and a theme colour item once, at the call that
+ DearPyGui reads an item's color argument and a theme color item once, at the call that
fills it, so those copies keep the shade of the palette that was active then. Handing a
- colour over through this registry keeps the :class:`BaseColor` alongside the copy, and
+ color over through this registry keeps the :class:`BaseColor` alongside the copy, and
:meth:`apply` hands DearPyGui the value each token carries now.
- One argument of one item holds one colour, so binding it again replaces what is recorded
- for it: an item recoloured on every hover stays a single entry.
+ One argument of one item holds one color, so binding it again replaces what is recorded
+ for it: an item recolored on every hover stays a single entry.
"""
_arguments: ClassVar[Dict[ArgumentKey, ArgumentBinding]] = {}
@@ -37,7 +37,7 @@ def bind(
*,
argument: str = COLOR_ARGUMENT,
) -> None:
- """Colours one of an item's arguments now, and keeps the token behind it."""
+ """Colors one of an item's arguments now, and keeps the token behind it."""
binding = ArgumentBinding(
item=item,
color=color,
@@ -48,7 +48,7 @@ def bind(
@classmethod
def bind_theme_color(cls, item: Sender, color: BaseColor) -> None:
- """Keeps the token behind a theme colour item the caller has just filled."""
+ """Keeps the token behind a theme color item the caller has just filled."""
cls._theme_colors[item] = ThemeColorBinding(
item=item,
color=color,
@@ -69,7 +69,7 @@ def apply(cls) -> None:
@classmethod
def bindings(cls) -> Iterator[PaletteBinding]:
- """Every colour copy the registry currently tracks."""
+ """Every color copy the registry currently tracks."""
return chain(cls._arguments.values(), cls._theme_colors.values())
@classmethod
diff --git a/src/sampletones_application/utils/gui/shortcuts/ids.py b/src/sampletones_application/utils/gui/shortcuts/ids.py
index dde05a076..afd04fd8d 100644
--- a/src/sampletones_application/utils/gui/shortcuts/ids.py
+++ b/src/sampletones_application/utils/gui/shortcuts/ids.py
@@ -1,10 +1,10 @@
from enum import Enum, StrEnum
-from typing import Dict, Final, Self, Tuple
+from typing import Dict, Final, FrozenSet, Self, Tuple
from sampletones_application.categories.hierarchy import Tab
from sampletones_application.constants.playback import FollowMode
-from sampletones_core.constants.enums import GeneratorName
-from sampletones_core.trackers.format import TrackerFormat
+from sampletones_core.constants.enums import ChannelName
+from sampletones_core.exports.format import ExportFormat
class ShortcutCategory(StrEnum):
@@ -20,7 +20,7 @@ class ShortcutCategory(StrEnum):
APPLICATION = "application"
ORDER = "order"
TRACKER = "tracker"
- SAMPLES = "samples"
+ VOICES = "voices"
DIALOG = "dialog"
@@ -48,6 +48,7 @@ def __new__(cls, value: str, category: ShortcutCategory) -> Self:
PROJECT_PROPERTIES = ("ProjectProperties", ShortcutCategory.APPLICATION)
EXPORT_PROJECT_FAMITRACKER = ("ExportProjectFamiTracker", ShortcutCategory.APPLICATION)
EXPORT_PROJECT_BITPHASE = ("ExportProjectBitphase", ShortcutCategory.APPLICATION)
+ EXPORT_PROJECT_NSF = ("ExportProjectNSF", ShortcutCategory.APPLICATION)
RENDER_SONG = ("RenderSong", ShortcutCategory.APPLICATION)
CLOSE_PROJECT = ("CloseProject", ShortcutCategory.APPLICATION)
EXIT = ("Exit", ShortcutCategory.APPLICATION)
@@ -64,6 +65,10 @@ def __new__(cls, value: str, category: ShortcutCategory) -> Self:
EXPORT_RECONSTRUCTION_WAV = ("ExportReconstructionWav", ShortcutCategory.APPLICATION)
EXPORT_INSTRUMENTS_FAMITRACKER = ("ExportInstrumentsFamiTracker", ShortcutCategory.APPLICATION)
EXPORT_INSTRUMENTS_BITPHASE_PRESET = ("ExportInstrumentsBitphasePreset", ShortcutCategory.APPLICATION)
+ EXPORT_INSTRUMENTS_NSF = ("ExportInstrumentsNSF", ShortcutCategory.APPLICATION)
+ NEW_INSTRUMENT = ("NewInstrument", ShortcutCategory.APPLICATION)
+ ADD_SAMPLE_FROM_FILE = ("AddSampleFromFile", ShortcutCategory.APPLICATION)
+ IMPORT_INSTRUMENT = ("ImportInstrument", ShortcutCategory.APPLICATION)
ADD_RECONSTRUCTION_TO_SEQUENCER = ("AddReconstructionToSequencer", ShortcutCategory.APPLICATION)
OPEN_RECONSTRUCTION_IN_EXPLORER = ("OpenReconstructionInExplorer", ShortcutCategory.APPLICATION)
LOCATE_ORIGINAL_AUDIO = ("LocateOriginalAudio", ShortcutCategory.APPLICATION)
@@ -180,13 +185,13 @@ def __new__(cls, value: str, category: ShortcutCategory) -> Self:
TRACKER_CANCEL_ENTRY = ("TrackerCancelEntry", ShortcutCategory.TRACKER)
TRACKER_PLAY_FROM_ROW = ("TrackerPlayFromRow", ShortcutCategory.TRACKER)
- SAMPLES_RENAME_SAMPLE = ("SamplesRenameSample", ShortcutCategory.SAMPLES)
- SAMPLES_REMOVE_SAMPLE = ("SamplesRemoveSample", ShortcutCategory.SAMPLES)
- SAMPLES_MOVE_SAMPLE_UP = ("SamplesMoveSampleUp", ShortcutCategory.SAMPLES)
- SAMPLES_MOVE_SAMPLE_DOWN = ("SamplesMoveSampleDown", ShortcutCategory.SAMPLES)
- SAMPLES_MOVE_SAMPLE_TO_TOP = ("SamplesMoveSampleToTop", ShortcutCategory.SAMPLES)
- SAMPLES_MOVE_SAMPLE_TO_BOTTOM = ("SamplesMoveSampleToBottom", ShortcutCategory.SAMPLES)
- SAMPLES_CANCEL_RENAME = ("SamplesCancelRename", ShortcutCategory.SAMPLES)
+ VOICES_RENAME_VOICE = ("VoicesRenameVoice", ShortcutCategory.VOICES)
+ VOICES_REMOVE_VOICE = ("VoicesRemoveVoice", ShortcutCategory.VOICES)
+ VOICES_MOVE_VOICE_UP = ("VoicesMoveVoiceUp", ShortcutCategory.VOICES)
+ VOICES_MOVE_VOICE_DOWN = ("VoicesMoveVoiceDown", ShortcutCategory.VOICES)
+ VOICES_MOVE_VOICE_TO_TOP = ("VoicesMoveVoiceToTop", ShortcutCategory.VOICES)
+ VOICES_MOVE_VOICE_TO_BOTTOM = ("VoicesMoveVoiceToBottom", ShortcutCategory.VOICES)
+ VOICES_CANCEL_RENAME = ("VoicesCancelRename", ShortcutCategory.VOICES)
DIALOG_NEXT_CONTROL = ("DialogNextControl", ShortcutCategory.DIALOG)
DIALOG_PREVIOUS_CONTROL = ("DialogPreviousControl", ShortcutCategory.DIALOG)
@@ -213,19 +218,33 @@ def __new__(cls, value: str, category: ShortcutCategory) -> Self:
Tab.INSTRUCTIONS: ShortcutId.SELECT_TAB_INSTRUCTIONS,
}
-CHANNEL_SHORTCUT_IDS: Final[Dict[GeneratorName, ShortcutId]] = {
- GeneratorName.PULSE1: ShortcutId.TOGGLE_CHANNEL_PULSE_1,
- GeneratorName.PULSE2: ShortcutId.TOGGLE_CHANNEL_PULSE_2,
- GeneratorName.TRIANGLE: ShortcutId.TOGGLE_CHANNEL_TRIANGLE,
- GeneratorName.NOISE: ShortcutId.TOGGLE_CHANNEL_NOISE,
+CHANNEL_SHORTCUT_IDS: Final[Dict[ChannelName, ShortcutId]] = {
+ ChannelName.PULSE1: ShortcutId.TOGGLE_CHANNEL_PULSE_1,
+ ChannelName.PULSE2: ShortcutId.TOGGLE_CHANNEL_PULSE_2,
+ ChannelName.TRIANGLE: ShortcutId.TOGGLE_CHANNEL_TRIANGLE,
+ ChannelName.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,
+PROJECT_EXPORT_SHORTCUT_IDS: Final[Dict[ExportFormat, ShortcutId]] = {
+ ExportFormat.FAMITRACKER: ShortcutId.EXPORT_PROJECT_FAMITRACKER,
+ ExportFormat.BITPHASE: ShortcutId.EXPORT_PROJECT_BITPHASE,
+ ExportFormat.NSF: ShortcutId.EXPORT_PROJECT_NSF,
}
-SAMPLE_EXPORT_SHORTCUT_IDS: Final[Dict[TrackerFormat, ShortcutId]] = {
- TrackerFormat.FAMITRACKER: ShortcutId.EXPORT_INSTRUMENTS_FAMITRACKER,
- TrackerFormat.BITPHASE_PRESET: ShortcutId.EXPORT_INSTRUMENTS_BITPHASE_PRESET,
+SAMPLE_EXPORT_SHORTCUT_IDS: Final[Dict[ExportFormat, ShortcutId]] = {
+ ExportFormat.FAMITRACKER: ShortcutId.EXPORT_INSTRUMENTS_FAMITRACKER,
+ ExportFormat.BITPHASE_PRESET: ShortcutId.EXPORT_INSTRUMENTS_BITPHASE_PRESET,
+ ExportFormat.NSF: ShortcutId.EXPORT_INSTRUMENTS_NSF,
}
+
+FAMILY_SHORTCUT_IDS: Final[FrozenSet[ShortcutId]] = frozenset(
+ shortcut_id
+ for family in (
+ FOLLOW_MODE_SHORTCUT_IDS,
+ TAB_SHORTCUT_IDS,
+ CHANNEL_SHORTCUT_IDS,
+ PROJECT_EXPORT_SHORTCUT_IDS,
+ SAMPLE_EXPORT_SHORTCUT_IDS,
+ )
+ for shortcut_id in family.values()
+)
diff --git a/src/sampletones_application/utils/gui/staging.py b/src/sampletones_application/utils/gui/staging.py
index f4f31af1f..7ebcba132 100644
--- a/src/sampletones_application/utils/gui/staging.py
+++ b/src/sampletones_application/utils/gui/staging.py
@@ -23,7 +23,7 @@ def create_stage() -> Sender:
def staged_container(stage: Sender) -> Iterator[None]:
"""Push ``stage`` as the active container so parentless items land in it.
- Items created with an explicit parent still honour that parent; the stage
+ Items created with an explicit parent still honor that parent; the stage
captures the parentless ones.
"""
with dpg_container(stage):
diff --git a/src/sampletones_application/utils/gui/tooltip.py b/src/sampletones_application/utils/gui/tooltip.py
index 63e3abbde..ddd051393 100644
--- a/src/sampletones_application/utils/gui/tooltip.py
+++ b/src/sampletones_application/utils/gui/tooltip.py
@@ -18,20 +18,40 @@ def show_tooltip(
message: str,
*,
tag: Optional[str] = None,
+ text_tag: Optional[str] = None,
) -> Sender:
- """Attaches a hover explanation to ``parent``, named by its tag or by the id it was created with."""
+ """Attaches a hover explanation to ``parent``, named by its tag or by the id it was created with.
+
+ ``text_tag`` names the message itself, which is what a caller gives it where the explanation
+ changes while the widget it explains stands.
+ """
tooltip_kwargs: SerializedData = {"hide_on_activity": True}
if tag is not None:
tooltip_kwargs["tag"] = tag
+ text_kwargs: SerializedData = {}
+ if text_tag is not None:
+ text_kwargs["tag"] = text_tag
+
with dpg.tooltip(parent, **tooltip_kwargs) as tooltip:
ThemeRegistry.get(TAG_GLOBAL_THEME_TOOLTIP).bind_to_item(tooltip)
- tooltip_text: Sender = dpg.add_text(message)
+ tooltip_text: Sender = dpg.add_text(message, **text_kwargs)
FontRegistry.bind_to_item(tooltip_text, Font.REGULAR_SMALL)
return tooltip_text
+def set_tooltip_visible(tag: str, visible: bool) -> None:
+ """Shows or hides a tooltip along with the widget it explains.
+
+ DearPyGui keeps a tooltip live over the rectangle its parent last drew at, so a tooltip left
+ showing while its widget is hidden explains whatever moved into that place. Toggling the two
+ together keeps an explanation on the control it belongs to.
+ """
+ if dpg.does_item_exist(tag):
+ dpg.configure_item(tag, show=visible)
+
+
def attach_disabled_tooltip(
parent: str,
message: str,
diff --git a/src/sampletones_application/utils/palette/colors/base.py b/src/sampletones_application/utils/palette/colors/base.py
index c8db56333..e4bf9f4a0 100644
--- a/src/sampletones_application/utils/palette/colors/base.py
+++ b/src/sampletones_application/utils/palette/colors/base.py
@@ -6,20 +6,20 @@
@dataclass(frozen=True)
class BaseColor(ABC):
- """A colour read at the moment it is drawn with.
+ """A color read at the moment it is drawn with.
- A colour keeps the form it was given rather than a value of its own, and :attr:`rgba`
+ A color keeps the form it was given rather than a value of its own, and :attr:`rgba`
answers with what that form reads under the palette active right now, so the same object
- gives a new colour once another palette is activated. Consumers hold the colour and read
+ gives a new color once another palette is activated. Consumers hold the color and read
:attr:`rgba` where they hand it to DearPyGui.
Each form is a frozen dataclass carrying what it was written or composed from, which makes
- a colour hashable by that form and lets a theme cache key on the shade it holds. A form
- composed from other colours reads them through this same property, so a shade taken from a
- token follows a palette swap along with the colour it came from.
+ a color hashable by that form and lets a theme cache key on the shade it holds. A form
+ composed from other colors reads them through this same property, so a shade taken from a
+ token follows a palette swap along with the color it came from.
"""
@property
@abstractmethod
def rgba(self) -> ColorRGBA:
- """The colour's value under the active palette."""
+ """The color's value under the active palette."""
diff --git a/src/sampletones_application/utils/palette/colors/blended.py b/src/sampletones_application/utils/palette/colors/blended.py
index cc77ad782..d46d8ee5d 100644
--- a/src/sampletones_application/utils/palette/colors/blended.py
+++ b/src/sampletones_application/utils/palette/colors/blended.py
@@ -7,7 +7,7 @@
@dataclass(frozen=True)
class BlendedColor(BaseColor):
- """A colour carried as a point on the gradient between two others, channel by channel."""
+ """A color carried as a point on the gradient between two others, channel by channel."""
start: BaseColor
end: BaseColor
diff --git a/src/sampletones_application/utils/palette/colors/faded.py b/src/sampletones_application/utils/palette/colors/faded.py
index a25f77976..5462570cd 100644
--- a/src/sampletones_application/utils/palette/colors/faded.py
+++ b/src/sampletones_application/utils/palette/colors/faded.py
@@ -7,12 +7,12 @@
@dataclass(frozen=True)
class FadedColor(BaseColor):
- """A colour carried at a fraction of full opacity, keeping its red, green and blue."""
+ """A color carried at a fraction of full opacity, keeping its red, green and blue."""
color: BaseColor
fraction: float
@property
def rgba(self) -> ColorRGBA:
- """The carried colour's value under the active palette, at the carried opacity."""
+ """The carried color's value under the active palette, at the carried opacity."""
return with_alpha_fraction(self.color.rgba, self.fraction)
diff --git a/src/sampletones_application/utils/palette/colors/grayscale.py b/src/sampletones_application/utils/palette/colors/grayscale.py
index b803eef08..f787f1a3c 100644
--- a/src/sampletones_application/utils/palette/colors/grayscale.py
+++ b/src/sampletones_application/utils/palette/colors/grayscale.py
@@ -7,11 +7,11 @@
@dataclass(frozen=True)
class GrayscaleColor(BaseColor):
- """A colour carried as the gray of the same luminance, keeping its alpha."""
+ """A color carried as the gray of the same luminance, keeping its alpha."""
color: BaseColor
@property
def rgba(self) -> ColorRGBA:
- """The carried colour's value under the active palette, desaturated."""
+ """The carried color's value under the active palette, desaturated."""
return to_grayscale(self.color.rgba)
diff --git a/src/sampletones_application/utils/palette/colors/layered.py b/src/sampletones_application/utils/palette/colors/layered.py
index 84af55932..eadd7297e 100644
--- a/src/sampletones_application/utils/palette/colors/layered.py
+++ b/src/sampletones_application/utils/palette/colors/layered.py
@@ -7,7 +7,7 @@
@dataclass(frozen=True)
class LayeredColor(BaseColor):
- """A colour carried as one wash drawn over another, kept as the two it was composed from.
+ """A color carried as one wash drawn over another, kept as the two it was composed from.
A surface that offers a single tint takes both washes through this form: the pair keeps
following the palette, and the value handed over is the shade the two make together.
diff --git a/src/sampletones_application/utils/palette/colors/literal.py b/src/sampletones_application/utils/palette/colors/literal.py
index 76ae0636c..14734b5db 100644
--- a/src/sampletones_application/utils/palette/colors/literal.py
+++ b/src/sampletones_application/utils/palette/colors/literal.py
@@ -6,11 +6,11 @@
@dataclass(frozen=True)
class LiteralColor(BaseColor):
- """A colour written as a ``#rrggbb`` value, standing on its own."""
+ """A color written as a ``#rrggbb`` value, standing on its own."""
value: ColorRGBA
@property
def rgba(self) -> ColorRGBA:
- """The value the colour was written with."""
+ """The value the color was written with."""
return self.value
diff --git a/src/sampletones_application/utils/palette/colors/named.py b/src/sampletones_application/utils/palette/colors/named.py
index 36b1c1fbf..9a159bf78 100644
--- a/src/sampletones_application/utils/palette/colors/named.py
+++ b/src/sampletones_application/utils/palette/colors/named.py
@@ -8,7 +8,7 @@
@dataclass(frozen=True)
class NamedColor(BaseColor):
- """A colour written as a palette reference, together with the source that answers it."""
+ """A color written as a palette reference, together with the source that answers it."""
reference: PaletteReference
source: PaletteSource
diff --git a/src/sampletones_application/utils/palette/colors/written.py b/src/sampletones_application/utils/palette/colors/written.py
index be2851530..0684e5753 100644
--- a/src/sampletones_application/utils/palette/colors/written.py
+++ b/src/sampletones_application/utils/palette/colors/written.py
@@ -13,7 +13,7 @@
def palette_source_from_context(info: ValidationInfo) -> PaletteSource:
- """The palette source a colour reference binds to, taken from the validation context.
+ """The palette source a color reference binds to, taken from the validation context.
Raises:
ValueError: when the context omits the palette source entry.
@@ -33,10 +33,10 @@ def palette_source_from_context(info: ValidationInfo) -> PaletteSource:
def _written_color(value: object, info: ValidationInfo) -> BaseColor:
- """The colour a configuration entry spells out, read once so its token answers at load.
+ """The color a configuration entry spells out, read once so its token answers at load.
An entry is written as a palette reference (``.token``, optionally ``.token/alpha``) or
- as a ``#rrggbb`` literal, and is kept in the form it was written. A colour built in code
+ as a ``#rrggbb`` literal, and is kept in the form it was written. A color built in code
passes through as it stands, which is how a derived shade reaches a field.
Raises:
@@ -47,7 +47,7 @@ def _written_color(value: object, info: ValidationInfo) -> BaseColor:
return value
if not isinstance(value, str):
- raise ValueError(f"A colour is written as a palette reference or a hex literal, got {type(value)}")
+ raise ValueError(f"A color is written as a palette reference or a hex literal, got {type(value)}")
text = value.strip()
color: BaseColor
diff --git a/src/sampletones_application/utils/palette/palette.py b/src/sampletones_application/utils/palette/palette.py
index b523df969..6b48bcf6d 100644
--- a/src/sampletones_application/utils/palette/palette.py
+++ b/src/sampletones_application/utils/palette/palette.py
@@ -12,9 +12,9 @@
class Palette(BaseModel, frozen=True):
- """A named set of semantic colour tokens shared across a theme set and the layout.
+ """A named set of semantic color tokens shared across a theme set and the layout.
- Colour fields reference these tokens by name so a colour is defined once and
+ Color fields reference these tokens by name so a color is defined once and
reused everywhere, and swapping the palette restyles every theme and layout entry
that resolves against it.
"""
@@ -33,7 +33,7 @@ def resolve(self, reference: PaletteReference) -> ColorRGBA:
"""
if reference.token not in self.colors:
raise KeyError(
- f"Palette {self.name!r} has no colour token {REFERENCE_PREFIX}{reference.token!r}. "
+ f"Palette {self.name!r} has no color token {REFERENCE_PREFIX}{reference.token!r}. "
f"Known tokens: {sorted(self.colors)}"
)
@@ -45,7 +45,7 @@ def resolve(self, reference: PaletteReference) -> ColorRGBA:
@classmethod
def load(cls, path: Path) -> Palette:
- """Load the palette that colour references resolve against.
+ """Load the palette that color references resolve against.
Raises:
TypeError: when the palette file holds a value other than a mapping.
diff --git a/src/sampletones_application/utils/palette/reference.py b/src/sampletones_application/utils/palette/reference.py
index 1e9438bdc..786bf8023 100644
--- a/src/sampletones_application/utils/palette/reference.py
+++ b/src/sampletones_application/utils/palette/reference.py
@@ -7,12 +7,12 @@
class PaletteReference(BaseModel, frozen=True):
- """A colour entry's reference to a named palette colour.
+ """A color entry's reference to a named palette color.
Written in YAML as ``.token`` or ``.token/alpha`` where ``alpha`` is a fraction
in ``[0, 1]`` that overrides the token's own alpha. The leading ``.`` marks the
value as a reference and keeps it distinct from a ``#rrggbb`` literal, so a
- colour field accepts either form in the same slot.
+ color field accepts either form in the same slot.
"""
token: str
diff --git a/src/sampletones_application/utils/palette/source.py b/src/sampletones_application/utils/palette/source.py
index 19a1fe7ec..412adaeb7 100644
--- a/src/sampletones_application/utils/palette/source.py
+++ b/src/sampletones_application/utils/palette/source.py
@@ -6,10 +6,10 @@
class PaletteSource(CallbackMixin):
- """The palette every colour token resolves against, and the one place it changes.
+ """The palette every color token resolves against, and the one place it changes.
A :class:`BaseColor` keeps the token it was written as and reads its value from
- here, so activating another palette gives every colour in the application a new
+ here, so activating another palette gives every color in the application a new
value with no reload and no re-injection. Whatever DearPyGui has already copied is
repainted by the listener on ``on_palette_changed``.
"""
@@ -23,11 +23,11 @@ def palette(self) -> Palette:
return self._palette
def activate(self, palette: Palette) -> None:
- """Make ``palette`` the one every colour token resolves against.
+ """Make ``palette`` the one every color token resolves against.
Announces the change once the swap is in place, so the listener reads the new
- colours as it repaints. Activating the palette already in place leaves both the
- colours and the listener untouched.
+ colors as it repaints. Activating the palette already in place leaves both the
+ colors and the listener untouched.
"""
if palette == self._palette:
return
diff --git a/src/sampletones_application/utils/parallelization/thread.py b/src/sampletones_application/utils/parallelization/thread.py
index d13f101c6..3c75aab52 100644
--- a/src/sampletones_application/utils/parallelization/thread.py
+++ b/src/sampletones_application/utils/parallelization/thread.py
@@ -11,7 +11,7 @@
CONCURRENT_EXECUTOR_NAME: Final[str] = "_concurrent_executor"
-class BackgroundWorkCancelled(Exception):
+class BackgroundWorkCanceled(Exception):
"""Unwinds a background task promptly once shutdown has been requested.
Long-running tasks poll :meth:`SingleThreadExecutor.is_shutting_down` at their
@@ -64,7 +64,7 @@ def request_shutdown(cls) -> None:
"""Signal running background tasks to wind down at their next cancellation point.
Set before :meth:`join_all` at teardown so an in-flight task raises
- :class:`BackgroundWorkCancelled` and finishes promptly, letting the join
+ :class:`BackgroundWorkCanceled` and finishes promptly, letting the join
return quickly.
"""
cls._shutdown.set()
@@ -140,7 +140,7 @@ def task() -> None:
try:
function(self, *args, **kwargs)
- except BackgroundWorkCancelled:
+ except BackgroundWorkCanceled:
return
except Exception as exception: # pylint: disable=broad-exception-caught
logger.error_with_traceback(
diff --git a/src/sampletones_application/view_model/main/converter.py b/src/sampletones_application/view_model/main/converter.py
index 89d5e1e31..4e6a3a87d 100644
--- a/src/sampletones_application/view_model/main/converter.py
+++ b/src/sampletones_application/view_model/main/converter.py
@@ -1,10 +1,15 @@
from enum import StrEnum
from pathlib import Path
-from typing import Final, FrozenSet, Optional
+from typing import Final, FrozenSet, Optional, Tuple
from pydantic import BaseModel
from sampletones_application.view_model.shared.percent import format_percent
+from sampletones_application.view_model.shared.stems import (
+ StemRowViewModel,
+ StemsListViewModel,
+)
+from sampletones_core.constants.enums import ChannelName, HierarchyMode
class ConversionPhase(StrEnum):
@@ -13,7 +18,7 @@ class ConversionPhase(StrEnum):
RUNNING = "running"
CANCELLING = "cancelling"
COMPLETED = "completed"
- CANCELLED = "cancelled"
+ CANCELED = "canceled"
FAILED = "failed"
@@ -54,6 +59,13 @@ class ConverterViewModel(BaseModel, frozen=True):
output_path: Optional[Path]
is_file: bool
other_operation_active: bool
+ stems_mode: bool
+ stem_sources: Tuple[StemRowViewModel, ...]
+ enabled_channels: FrozenSet[ChannelName]
+ channel_cap: int
+ max_channel_cap: int
+ hierarchy_mode: HierarchyMode
+ max_sources: int
@property
def progress_overlay(self) -> str:
@@ -68,9 +80,56 @@ def is_active(self) -> bool:
def subpanel_visible(self) -> bool:
return self.phase != ConversionPhase.IDLE
+ @property
+ def has_input(self) -> bool:
+ """Something is there to convert: a listed recording holding a channel, or a selected path."""
+ if self.stems_mode:
+ return any(row.takes_part for row in self.stem_sources)
+
+ return self.input_path is not None
+
+ @property
+ def source_count(self) -> int:
+ return len(self.stem_sources)
+
+ @property
+ def channels_in_play(self) -> Tuple[ChannelName, ...]:
+ """The channels a conversion may reach, in the order the application names them.
+
+ A stems row offers a checkbox per channel in play, so a channel the configuration leaves
+ out costs the row no column at all.
+ """
+ return tuple(channel_name for channel_name in ChannelName.items() if channel_name in self.enabled_channels)
+
+ @property
+ def stems_list(self) -> StemsListViewModel:
+ """The gathered recordings as the stems list draws them, inert while a conversion runs."""
+ return StemsListViewModel(
+ rows=self.stem_sources,
+ channels_in_play=self.channels_in_play,
+ muted_channels=frozenset(),
+ live=not self.is_active,
+ collapse_levels=False,
+ )
+
+ @property
+ def level_count(self) -> int:
+ """How many levels the gathered recordings are spread over."""
+ return max((row.level + 1 for row in self.stem_sources), default=0)
+
+ @property
+ def playing_count(self) -> int:
+ """How many of the listed recordings take part in the conversion."""
+ return sum(1 for row in self.stem_sources if row.takes_part)
+
+ @property
+ def can_add_source(self) -> bool:
+ """The list has room for another recording."""
+ return self.source_count < self.max_sources
+
@property
def convert_button_enabled(self) -> bool:
- return self.phase == ConversionPhase.IDLE and self.input_path is not None and not self.other_operation_active
+ return self.phase == ConversionPhase.IDLE and self.has_input and not self.other_operation_active
@property
def primary_action(self) -> ConverterAction:
diff --git a/src/sampletones_application/view_model/main/reconstructor.py b/src/sampletones_application/view_model/main/reconstructor.py
index 06c155402..7799a6d61 100644
--- a/src/sampletones_application/view_model/main/reconstructor.py
+++ b/src/sampletones_application/view_model/main/reconstructor.py
@@ -2,9 +2,9 @@
from pydantic import BaseModel
-from sampletones_core.constants.enums import GeneratorName
+from sampletones_core.constants.enums import ChannelName
class ReconstructorPanelViewModel(BaseModel, frozen=True):
- generators: FrozenSet[GeneratorName]
+ channels: FrozenSet[ChannelName]
drive: float
diff --git a/src/sampletones_application/view_model/main/updates.py b/src/sampletones_application/view_model/main/updates.py
index 1c5b0d5ee..a42aeecb8 100644
--- a/src/sampletones_application/view_model/main/updates.py
+++ b/src/sampletones_application/view_model/main/updates.py
@@ -3,7 +3,7 @@
from pydantic import BaseModel
-from sampletones_core.constants.enums import GeneratorName, SpectrumMethod
+from sampletones_core.constants.enums import ChannelName, SpectrumMethod
class AudioSettingsUpdate(BaseModel, frozen=True):
@@ -18,7 +18,7 @@ class LibrarySettingsUpdate(BaseModel, frozen=True):
class GenerationSettingsUpdate(BaseModel, frozen=True):
drive: float
- generators: List[GeneratorName]
+ channels: List[ChannelName]
class AdvancedSettingsUpdate(BaseModel, frozen=True):
diff --git a/src/sampletones_application/view_model/reconstruction/instruments.py b/src/sampletones_application/view_model/reconstruction/instruments.py
index 15c808fb7..b298605a1 100644
--- a/src/sampletones_application/view_model/reconstruction/instruments.py
+++ b/src/sampletones_application/view_model/reconstruction/instruments.py
@@ -2,18 +2,40 @@
from pydantic import BaseModel
-from sampletones_application.view_model.shared.footprint import SampleFootprintViewModel
-from sampletones_core.constants.enums import GeneratorName
+from sampletones_application.view_model.shared.footprint import VoiceFootprintViewModel
+from sampletones_core.constants.enums import ChannelName
+
+
+class InstrumentViewModel(BaseModel, frozen=True):
+ """What the instruments panel shows of an instrument, which is the name it is titled by.
+
+ An instrument is one set of envelopes every channel reads, so the panel renders one instrument
+ rather than a tab per channel, and each dimension states the item it repeats from itself.
+ """
+
+ name: str
class ReconstructionInstrumentsViewModel(BaseModel, frozen=True):
- """What the instruments panel renders: every channel, and which of them play.
+ """What the instruments panel renders: the voice in front of it, and how it is read.
A reconstruction holds a tab per channel whatever it sounds, so a channel standing by stays
- editable and giving it an envelope puts it in play. :attr:`playing_generators` is what the
- panel reads to mark the standing-by tabs and to offer their export.
+ editable and giving it an envelope puts it in play. :attr:`playing_channels` is what the panel
+ reads to mark the standing-by tabs and to offer their export. An instrument is one set every
+ channel reads, so :attr:`instrument` is what the panel renders instead.
"""
reconstruction_loaded: bool
- playing_generators: FrozenSet[GeneratorName]
- footprint: Optional[SampleFootprintViewModel]
+ playing_channels: FrozenSet[ChannelName]
+ footprint: Optional[VoiceFootprintViewModel]
+ instrument: Optional[InstrumentViewModel] = None
+
+ @property
+ def edits_an_instrument(self) -> bool:
+ """Whether the panel is showing an instrument rather than a reconstruction's channels."""
+ return self.instrument is not None
+
+ @property
+ def is_open(self) -> bool:
+ """Whether the panel has a voice in front of it at all."""
+ return self.reconstruction_loaded or self.edits_an_instrument
diff --git a/src/sampletones_application/view_model/reconstruction/paths/__init__.py b/src/sampletones_application/view_model/reconstruction/paths/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/src/sampletones_application/view_model/reconstruction/paths/path.py b/src/sampletones_application/view_model/reconstruction/paths/path.py
new file mode 100644
index 000000000..052abe6be
--- /dev/null
+++ b/src/sampletones_application/view_model/reconstruction/paths/path.py
@@ -0,0 +1,18 @@
+from typing import Tuple
+
+from pydantic import BaseModel
+
+from sampletones_application.view_model.reconstruction.paths.state import ReconstructionPathState
+
+
+class ReconstructionPathViewModel(BaseModel, frozen=True):
+ state: ReconstructionPathState
+ paths: Tuple[str, ...] = ()
+
+ @property
+ def path(self) -> str:
+ """The single path the location carries, empty while it holds none or several."""
+ if len(self.paths) == 1:
+ return self.paths[0]
+
+ return ""
diff --git a/src/sampletones_application/view_model/reconstruction/paths/state.py b/src/sampletones_application/view_model/reconstruction/paths/state.py
new file mode 100644
index 000000000..8889b27ad
--- /dev/null
+++ b/src/sampletones_application/view_model/reconstruction/paths/state.py
@@ -0,0 +1,49 @@
+from __future__ import annotations
+
+from enum import StrEnum
+from pathlib import Path
+from typing import Final, Tuple
+
+
+class ReconstructionPathState(StrEnum):
+ """Whether a path can be shown for a reconstruction location.
+
+ ``AVAILABLE`` carries one resolvable path. ``MULTIPLE`` carries several paths recorded
+ for one location (a stems reconstruction's source files). ``NOT_FOUND`` marks a
+ recorded path whose file is absent on this machine. ``NOT_APPLICABLE`` marks a
+ location that a reconstruction does not have (a sequencer sample keeps no file
+ locations). ``EMPTY`` is the resting state when no reconstruction is loaded.
+ """
+
+ AVAILABLE = "available"
+ MULTIPLE = "multiple"
+ NOT_FOUND = "not_found"
+ NOT_APPLICABLE = "not_applicable"
+ EMPTY = "empty"
+
+ @classmethod
+ def from_source_paths(
+ cls,
+ source_paths: Tuple[Path, ...],
+ ) -> ReconstructionPathState:
+ """Returns the state a recorded location takes: not-applicable with no path,
+ available with one, multiple with several."""
+ if not source_paths:
+ return cls.NOT_APPLICABLE
+
+ if len(source_paths) == 1:
+ return cls.AVAILABLE
+
+ return cls.MULTIPLE
+
+
+RECORDED_PATH_STATES: Final[Tuple[ReconstructionPathState, ...]] = (
+ ReconstructionPathState.AVAILABLE,
+ ReconstructionPathState.MULTIPLE,
+ ReconstructionPathState.NOT_FOUND,
+)
+
+PLAYABLE_PATH_STATES: Final[Tuple[ReconstructionPathState, ...]] = (
+ ReconstructionPathState.AVAILABLE,
+ ReconstructionPathState.MULTIPLE,
+)
diff --git a/src/sampletones_application/view_model/reconstruction/reconstruction.py b/src/sampletones_application/view_model/reconstruction/reconstruction.py
index 7c48cf0e3..1279d3496 100644
--- a/src/sampletones_application/view_model/reconstruction/reconstruction.py
+++ b/src/sampletones_application/view_model/reconstruction/reconstruction.py
@@ -1,56 +1,40 @@
-from enum import StrEnum
-from typing import Final, FrozenSet, Tuple
+from typing import FrozenSet, Optional
from pydantic import BaseModel
-from sampletones_core.constants.enums import GeneratorName
+from sampletones_core.constants.enums import ChannelName
-
-class ReconstructionPathState(StrEnum):
- """Whether a path can be shown for a reconstruction location.
-
- ``AVAILABLE`` carries a resolvable path. ``NOT_FOUND`` marks a recorded path whose
- file is absent on this machine. ``NOT_APPLICABLE`` marks a location that a
- reconstruction does not have (a sequencer sample keeps no file locations).
- ``EMPTY`` is the resting state when no reconstruction is loaded.
- """
-
- AVAILABLE = "available"
- NOT_FOUND = "not_found"
- NOT_APPLICABLE = "not_applicable"
- EMPTY = "empty"
-
-
-RECORDED_PATH_STATES: Final[Tuple[ReconstructionPathState, ...]] = (
- ReconstructionPathState.AVAILABLE,
- ReconstructionPathState.NOT_FOUND,
+from .paths.path import ReconstructionPathViewModel
+from .paths.state import (
+ PLAYABLE_PATH_STATES,
+ RECORDED_PATH_STATES,
+ ReconstructionPathState,
)
-class ReconstructionPathViewModel(BaseModel, frozen=True):
- state: ReconstructionPathState
- path: str
-
-
class ReconstructionViewModel(BaseModel, frozen=True):
"""What the reconstruction view renders, including which channels the waveform offers.
A channel plays once its instruction stream describes a frame, which is what makes its
- generator checkbox reachable; :attr:`selected_generators` is the subset the reader keeps
+ channel checkbox reachable; :attr:`selected_channels` is the subset the reader keeps
switched on, so a channel switched off by hand stays off across an edit.
+
+ :attr:`nes_frequency` is the engine rate the open reconstruction runs at, and ``None``
+ while the tab holds no document.
"""
reconstruction_loaded: bool
- playing_generators: FrozenSet[GeneratorName]
- selected_generators: FrozenSet[GeneratorName]
+ playing_channels: FrozenSet[ChannelName]
+ selected_channels: FrozenSet[ChannelName]
reconstruction_file: ReconstructionPathViewModel
original_audio: ReconstructionPathViewModel
+ nes_frequency: Optional[int]
@property
def audio_source_enabled(self) -> bool:
"""The source toggle offers the original audio once its file is present on disk;
until then playback stays on the reconstruction."""
- return self.original_audio.state is ReconstructionPathState.AVAILABLE
+ return self.original_audio.state in PLAYABLE_PATH_STATES
@property
def locate_audio_enabled(self) -> bool:
diff --git a/src/sampletones_application/view_model/reconstruction/stems.py b/src/sampletones_application/view_model/reconstruction/stems.py
new file mode 100644
index 000000000..7a9a6e30c
--- /dev/null
+++ b/src/sampletones_application/view_model/reconstruction/stems.py
@@ -0,0 +1,30 @@
+from typing import Optional
+
+from pydantic import BaseModel
+
+from sampletones_application.view_model.shared.stems import StemsListViewModel
+from sampletones_core.constants.enums import HierarchyMode
+
+
+class ReconstructionStemsViewModel(BaseModel, frozen=True):
+ """What the stems card renders for the loaded reconstruction.
+
+ The recorded assignment is a stems list like the converter's, so the card hands
+ :attr:`stems` straight to the shared element and keeps the setup line describing how the
+ levels were picked.
+ """
+
+ reconstruction_loaded: bool
+ stems: StemsListViewModel
+ hierarchy_mode: Optional[HierarchyMode] = None
+ channel_cap: Optional[int] = None
+
+ @property
+ def show_setup_line(self) -> bool:
+ """The setup line states the hierarchy mode and cap a stems record carries."""
+ return self.hierarchy_mode is not None
+
+ @property
+ def show_empty_state(self) -> bool:
+ """The empty state explains a loaded reconstruction that records no source."""
+ return self.reconstruction_loaded and not self.stems.rows
diff --git a/src/sampletones_application/view_model/reconstruction/update.py b/src/sampletones_application/view_model/reconstruction/update.py
index 81f525015..867c0fe63 100644
--- a/src/sampletones_application/view_model/reconstruction/update.py
+++ b/src/sampletones_application/view_model/reconstruction/update.py
@@ -1,10 +1,10 @@
from typing import NamedTuple
-from sampletones_core.constants.enums import FeatureKey, GeneratorName
-from sampletones_core.types.feature import FeatureValue
+from sampletones_core.constants.enums import ChannelName, FeatureKey
+from sampletones_core.exporters import Features
class ReconstructionUpdate(NamedTuple):
- generator_name: GeneratorName
+ channel_name: ChannelName
feature_key: FeatureKey
- data: FeatureValue
+ features: Features
diff --git a/src/sampletones_application/view_model/reconstruction/waveform.py b/src/sampletones_application/view_model/reconstruction/waveform.py
new file mode 100644
index 000000000..2dabc18ee
--- /dev/null
+++ b/src/sampletones_application/view_model/reconstruction/waveform.py
@@ -0,0 +1,25 @@
+from dataclasses import dataclass
+
+import numpy as np
+
+from sampletones_core.constants.enums import ChannelName
+
+
+@dataclass(frozen=True)
+class InstrumentWaveformViewModel:
+ """What a hand-written voice sounds like, as the plot card draws it.
+
+ An instrument stands on no recording, so the card shows the audio its envelopes make on the
+ generator chosen to hear it: one line under the voice's own name, in that generator's color.
+
+ Attributes:
+ name: The name the series is labeled by, which is the voice's own.
+ channel_name: The channel the audio was rendered on, naming the color it is drawn in.
+ audio: The waveform, one frame per tick the envelopes describe.
+ frame_length: The samples one frame spans, which the per-frame overlay reads.
+ """
+
+ name: str
+ channel_name: ChannelName
+ audio: np.ndarray
+ frame_length: int
diff --git a/src/sampletones_application/view_model/sequencer/channels.py b/src/sampletones_application/view_model/sequencer/channels.py
index bd2cb0cf3..258136fd0 100644
--- a/src/sampletones_application/view_model/sequencer/channels.py
+++ b/src/sampletones_application/view_model/sequencer/channels.py
@@ -2,7 +2,7 @@
from pydantic import BaseModel
-from sampletones_core.constants.enums import GeneratorName
+from sampletones_core.constants.enums import ChannelName
class SequencerChannelsViewModel(BaseModel, frozen=True):
@@ -12,18 +12,18 @@ class SequencerChannelsViewModel(BaseModel, frozen=True):
export, and the history stack all read the full song.
"""
- muted: FrozenSet[GeneratorName]
+ muted: FrozenSet[ChannelName]
- def is_muted(self, generator: GeneratorName) -> bool:
- return generator in self.muted
+ def is_muted(self, channel: ChannelName) -> bool:
+ return channel in self.muted
- def is_soloed(self, generator: GeneratorName) -> bool:
- """Whether ``generator`` is the one channel left sounding.
+ def is_soloed(self, channel: ChannelName) -> bool:
+ """Whether ``channel`` is the one channel left sounding.
Solo is read back from the mute set the same way it is applied, so the menu names the
gesture by what the mix currently sounds like.
"""
- return self.muted == frozenset(GeneratorName.items()) - {generator}
+ return self.muted == frozenset(ChannelName.items()) - {channel}
@property
def any_muted(self) -> bool:
@@ -33,4 +33,4 @@ def any_muted(self) -> bool:
@property
def all_muted(self) -> bool:
"""Whether every channel is silenced, the state the master column's click restores from."""
- return self.muted == frozenset(GeneratorName.items())
+ return self.muted == frozenset(ChannelName.items())
diff --git a/src/sampletones_application/view_model/sequencer/kind.py b/src/sampletones_application/view_model/sequencer/kind.py
new file mode 100644
index 000000000..b00335897
--- /dev/null
+++ b/src/sampletones_application/view_model/sequencer/kind.py
@@ -0,0 +1,43 @@
+from typing import Optional
+
+from sampletones_application.view_model.sequencer.voices import VoiceKind
+from sampletones_core.constants.enums import ChannelName
+from sampletones_core.project.voices.instrument import Instrument
+from sampletones_core.project.voices.sample import Sample
+from sampletones_core.project.voices.voice import VoiceUnion
+
+
+def voice_kind(voice: VoiceUnion) -> VoiceKind:
+ """Which of the two kinds a voice is, as the list marks it.
+
+ Args:
+ voice: The voice being listed.
+
+ Returns:
+ VoiceKind: The kind the row shows and its gestures follow from.
+ """
+ match voice:
+ case Sample():
+ return VoiceKind.SAMPLE
+ case Instrument():
+ return VoiceKind.INSTRUMENT
+
+
+def column_takes(channel: Optional[ChannelName], kind: VoiceKind) -> bool:
+ """Whether the column a cell stands in places a voice of this kind.
+
+ A channel column sounds whatever it is given, so it takes either kind. The sample column
+ spreads a voice over the channels it covers, which a recording states and a hand-written voice
+ does not, so it takes a recording alone.
+
+ Args:
+ channel: The channel the column carries, ``None`` for the sample column.
+ kind: The kind of the voice being placed.
+
+ Returns:
+ bool: Whether the column takes it.
+ """
+ if channel is not None:
+ return True
+
+ return kind.places_across_channels
diff --git a/src/sampletones_application/view_model/sequencer/move.py b/src/sampletones_application/view_model/sequencer/move.py
index 5490d1045..eb662da0c 100644
--- a/src/sampletones_application/view_model/sequencer/move.py
+++ b/src/sampletones_application/view_model/sequencer/move.py
@@ -5,7 +5,7 @@
class MoveDirection(Enum):
"""A reorder action: move an item toward the start or end of its sequence.
- Shared by the instruments list (vertical) and the order table (horizontal);
+ Shared by the voices list (vertical) and the order table (horizontal);
the axis-neutral names map to up/left (``PREVIOUS``), down/right (``NEXT``),
top/start (``FIRST``) and bottom/end (``LAST``) at each call site.
"""
@@ -18,7 +18,7 @@ class MoveDirection(Enum):
def target(self, position: int, count: int) -> Optional[int]:
"""Resolve the destination index, or ``None`` when the move would keep the position where it is.
- ``None`` is the grey-out signal: a move toward the start from the first
+ ``None`` is the gray-out signal: a move toward the start from the first
position, or toward the end from the last, already sits at the boundary, so
the menu disables that item.
"""
diff --git a/src/sampletones_application/view_model/sequencer/order.py b/src/sampletones_application/view_model/sequencer/order.py
index 8a2246705..0fdb89eee 100644
--- a/src/sampletones_application/view_model/sequencer/order.py
+++ b/src/sampletones_application/view_model/sequencer/order.py
@@ -3,7 +3,7 @@
from pydantic import BaseModel
from sampletones_application.view_model.sequencer.aggregate import aggregate_labels
-from sampletones_core.constants.enums import GeneratorName
+from sampletones_core.constants.enums import ChannelName
from sampletones_core.utils.display import display_id
@@ -19,28 +19,28 @@ def label(self) -> str:
class SequencerOrderViewModel(BaseModel, frozen=True):
"""The pattern sequence for one channel."""
- generator: GeneratorName
+ channel: ChannelName
entries: Tuple[OrderEntryViewModel, ...]
class SequencerOrderTrackerViewModel(BaseModel, frozen=True):
"""The whole arrangement: order positions (columns) across channels (rows).
- The master row summarises each position across channels — the horizontal analog
+ The master row summarizes each position across channels — the horizontal analog
of the tracker's sample column — showing the shared pattern index or ``?`` when
the channels disagree.
"""
position_count: int
- channels: Dict[GeneratorName, SequencerOrderViewModel]
+ channels: Dict[ChannelName, SequencerOrderViewModel]
- def entry_label(self, generator: GeneratorName, position: int) -> str:
- entries = self.channels[generator].entries
+ def entry_label(self, channel: ChannelName, position: int) -> str:
+ entries = self.channels[channel].entries
if position < len(entries):
return entries[position].label
return display_id(None)
def master_label(self, position: int) -> str:
- values = {self.entry_label(generator, position) for generator in self.channels}
+ values = {self.entry_label(channel, position) for channel in self.channels}
return aggregate_labels(values, default=display_id(None))
diff --git a/src/sampletones_application/view_model/sequencer/region.py b/src/sampletones_application/view_model/sequencer/region.py
index 04249d7b8..8ddd0aa2d 100644
--- a/src/sampletones_application/view_model/sequencer/region.py
+++ b/src/sampletones_application/view_model/sequencer/region.py
@@ -8,7 +8,7 @@
TrackerSlot,
slot_from_flat,
)
-from sampletones_core.constants.enums import GeneratorName
+from sampletones_core.constants.enums import ChannelName
class TrackerCell(BaseModel, frozen=True):
@@ -20,13 +20,13 @@ class TrackerCell(BaseModel, frozen=True):
"""
row: int = Field(ge=0)
- generator: Optional[GeneratorName]
+ channel: Optional[ChannelName]
class OrderCell(BaseModel, frozen=True):
"""The order cell a block is written from: a channel row, and the position it starts in."""
- generator: Optional[GeneratorName]
+ channel: Optional[ChannelName]
position: int = Field(ge=0)
@@ -81,14 +81,14 @@ def slots(self) -> Tuple[TrackerSlot, ...]:
return tuple(slot_from_flat(index) for index in range(self.first_slot, self.last_slot + 1))
@property
- def columns(self) -> Tuple[Optional[GeneratorName], ...]:
+ def columns(self) -> Tuple[Optional[ChannelName], ...]:
"""The columns the region reaches, each named once and in the order the axis lays them out.
A region names its edges as subcolumns, while a gesture acting on whole cells — a transpose
or a volume shift — reaches the columns behind them. The sample column reads ``None``, as it
does everywhere the axis is read.
"""
- return tuple(dict.fromkeys(slot.generator for slot in self.slots))
+ return tuple(dict.fromkeys(slot.channel for slot in self.slots))
def covers(self, row: int, slot: TrackerSlot) -> bool:
"""Whether a cell of the grid falls inside the rectangle.
@@ -125,13 +125,13 @@ def positions(self) -> range:
return range(self.first_position, self.last_position + 1)
@property
- def generators(self) -> Tuple[Optional[GeneratorName], ...]:
+ def channels(self) -> Tuple[Optional[ChannelName], ...]:
"""The rows the region covers, each as the channel it addresses, master reading ``None``."""
return tuple(CHANNEL_AXIS[row] for row in self.rows)
def covers(
self,
- generator: Optional[GeneratorName],
+ channel: Optional[ChannelName],
position: int,
) -> bool:
"""Whether a cell of the table falls inside the rectangle.
@@ -139,4 +139,4 @@ def covers(
This is what a gesture raised on a cell asks to learn which block it belongs to: one
landing inside a selection acts on the whole of it, and one landing outside acts alone.
"""
- return self.covers_row(CHANNEL_AXIS.index(generator)) and position in self.positions
+ return self.covers_row(CHANNEL_AXIS.index(channel)) and position in self.positions
diff --git a/src/sampletones_application/view_model/sequencer/samples.py b/src/sampletones_application/view_model/sequencer/samples.py
deleted file mode 100644
index fe105a2dd..000000000
--- a/src/sampletones_application/view_model/sequencer/samples.py
+++ /dev/null
@@ -1,35 +0,0 @@
-from typing import Tuple
-
-from pydantic import BaseModel
-
-from sampletones_core.utils.display import display_sample_label
-
-
-class SampleEntryViewModel(BaseModel, frozen=True):
- sample_id: str
- name: str
- loop: bool
-
-
-class SampleSelection(BaseModel, frozen=True):
- """The samples panel's selected row, offered to the sequencer as an operation target.
-
- Carries the sample's identity for acting on it and its position for naming it, so an
- operation reached from elsewhere in the tab — the browser's replace item — addresses the
- selection the same way the samples panel displays it.
- """
-
- sample_id: str
- position: int
- name: str
-
- @property
- def label(self) -> str:
- """The sample's list label, matching how the samples panel and tracker name it."""
- return display_sample_label(self.position, self.name)
-
-
-class SequencerSamplesViewModel(BaseModel, frozen=True):
- """The ordered sample pool shown in the right-hand samples panel."""
-
- samples: Tuple[SampleEntryViewModel, ...]
diff --git a/src/sampletones_application/view_model/sequencer/slot.py b/src/sampletones_application/view_model/sequencer/slot.py
index f4902ea38..5c4e6f354 100644
--- a/src/sampletones_application/view_model/sequencer/slot.py
+++ b/src/sampletones_application/view_model/sequencer/slot.py
@@ -6,7 +6,7 @@
from sampletones_application.constants.sequencer import CHANNEL_AXIS
from sampletones_application.view_model.sequencer.subcolumn import SubColumn
-from sampletones_core.constants.enums import GeneratorName
+from sampletones_core.constants.enums import ChannelName
SUBCOLUMNS: Final[Tuple[SubColumn, ...]] = tuple(SubColumn)
SLOT_COUNT: Final[int] = len(CHANNEL_AXIS) * len(SUBCOLUMNS)
@@ -23,22 +23,22 @@ class TrackerSlot:
while an edit addresses the column and the subcolumn it lands in.
"""
- generator: Optional[GeneratorName]
+ channel: Optional[ChannelName]
subcolumn: SubColumn
@property
def flat_index(self) -> int:
- return column_slot_base(self.generator) + SUBCOLUMNS.index(self.subcolumn)
+ return column_slot_base(self.channel) + SUBCOLUMNS.index(self.subcolumn)
-def column_slot_base(generator: Optional[GeneratorName]) -> int:
- """The flat index of ``generator``'s first subcolumn.
+def column_slot_base(channel: Optional[ChannelName]) -> int:
+ """The flat index of ``channel``'s first subcolumn.
Every base is a multiple of ``len(SUBCOLUMNS)``, which is what keeps an offset
measured from one column's base addressing the same kind of subcolumn at any
other column it is replayed against.
"""
- return CHANNEL_AXIS.index(generator) * len(SUBCOLUMNS)
+ return CHANNEL_AXIS.index(channel) * len(SUBCOLUMNS)
def slot_from_flat(index: int) -> TrackerSlot:
diff --git a/src/sampletones_application/view_model/sequencer/subcolumn.py b/src/sampletones_application/view_model/sequencer/subcolumn.py
index 27201b910..d4b11552c 100644
--- a/src/sampletones_application/view_model/sequencer/subcolumn.py
+++ b/src/sampletones_application/view_model/sequencer/subcolumn.py
@@ -2,6 +2,6 @@
class SubColumn(StrEnum):
- INSTRUMENT = "instrument"
+ VOICE = "voice"
TRANSPOSE = "transpose"
VOLUME = "volume"
diff --git a/src/sampletones_application/view_model/sequencer/tracker.py b/src/sampletones_application/view_model/sequencer/tracker.py
index 54012c51f..bad11b612 100644
--- a/src/sampletones_application/view_model/sequencer/tracker.py
+++ b/src/sampletones_application/view_model/sequencer/tracker.py
@@ -1,63 +1,98 @@
-from typing import Callable, Dict, FrozenSet, Set, Tuple
+from typing import Callable, Dict, FrozenSet, Optional, Set, Tuple
from pydantic import BaseModel
from sampletones_application.view_model.sequencer.aggregate import aggregate_labels
-from sampletones_core.constants.enums import GeneratorName
+from sampletones_application.view_model.sequencer.voices import VoiceKind
+from sampletones_core.constants.enums import ChannelName
from sampletones_core.utils.display import (
display_id,
display_transpose,
display_volume,
)
+from sampletones_shared.utils.agreement import Agreement
class SequencerCellViewModel(BaseModel, frozen=True):
"""One channel cell on one tracker row, pre-formatted for display.
The columns are produced by :mod:`sampletones_core.utils.display`, the single
- source of tracker cell formatting (sample position, transpose, volume). The
+ source of tracker cell formatting (voice position, transpose, volume). The
tracker grid renders :attr:`label`, the combined cell text.
"""
- instrument: str
+ voice: str
transpose: str
volume: str
+ kind: Optional[VoiceKind]
+ """The kind of the voice this cell names, absent where it names none.
+
+ The cell reads its voice by list position, which says nothing about what that voice is. The
+ kind travels beside it so a reader of the cell — the sample column's summary, the color the
+ slot takes — knows which of the two it is looking at.
+ """
@property
def label(self) -> str:
- return f"{self.instrument} {self.transpose} {self.volume}"
+ return f"{self.voice} {self.transpose} {self.volume}"
+
+
+def _sample_reading(cell: SequencerCellViewModel) -> str:
+ """A cell's voice reading as the sample column speaks it.
+
+ The column places a recording over the channels it covers, so it reads a sample by its number
+ and a cut as a cut. A hand-written instrument is placed in the channel column that names it, so
+ it reads as empty here and leaves the summary to the channels the column governs.
+ """
+ if cell.kind is VoiceKind.INSTRUMENT:
+ return display_id(None)
+
+ return cell.voice
class SequencerRowViewModel(BaseModel, frozen=True):
index: int
- cells: Dict[GeneratorName, SequencerCellViewModel]
- relevant_generators: FrozenSet[GeneratorName]
- """Channels the row's sample(s) span — the union of their reconstructions' channels.
-
- The sample column summarises a subcolumn only across these channels, so a
- sample that spans more channels than it currently occupies reads as mixed.
+ cells: Dict[ChannelName, SequencerCellViewModel]
+ sample_channels: FrozenSet[ChannelName]
+ """Channels the row's samples span — the union of their reconstructions' channels.
+
+ A sample governs the channels its reconstruction covers, so the sample column reads its
+ reference across exactly those and a sample missing from one of them reads as mixed. A row
+ naming only hand-written instruments spans none, since each of those sounds on the one
+ channel it is named in.
"""
@property
- def subcolumn_generators(self) -> FrozenSet[GeneratorName]:
+ def subcolumn_channels(self) -> FrozenSet[ChannelName]:
"""Channels every sample column summary spans.
A sample governs the channels its reconstruction covers, so its subcolumns
- summarise exactly those. Transpose and volume exist independently of an
- instrument, so a row with no sample spans every channel.
+ summarize exactly those. Transpose and volume stand on their own, so a row
+ naming no sample spans every channel.
"""
- return self.relevant_generators or frozenset(self.cells)
+ return self.sample_channels or frozenset(self.cells)
+
+ @property
+ def sample(self) -> str:
+ return self._aggregate(_sample_reading, display_id(None))
@property
- def sample_instrument(self) -> str:
- return self._aggregate(lambda cell: cell.instrument, display_id(None))
+ def sample_kind(self) -> Optional[VoiceKind]:
+ """The kind of voice the sample column's slot names, absent where its channels disagree.
+
+ The slot speaks for the channels the row's samples cover, so it states a kind where every
+ one of those channels names a voice of that kind. A row naming no sample covers none and
+ states none, which is what an empty slot and a cut row are.
+ """
+ kinds = Agreement.collapse(self.cells[channel].kind for channel in self.sample_channels)
+ return kinds.resolve(absent=None, mixed=None)
@property
- def sample_transpose(self) -> str:
+ def transpose(self) -> str:
return self._aggregate(lambda cell: cell.transpose, display_transpose(None))
@property
- def sample_volume(self) -> str:
+ def volume(self) -> str:
return self._aggregate(lambda cell: cell.volume, display_volume(None))
def _aggregate(
@@ -65,14 +100,14 @@ def _aggregate(
select: Callable[[SequencerCellViewModel], str],
default: str,
) -> str:
- """Summarise one subcolumn across the channels the sample column spans.
+ """Summarize one subcolumn across the channels the sample column spans.
The summary holds a value only where every channel agrees on it, so
:data:`MIXED` marks each way they can differ: a sample missing from one of
its channels, a transpose set on some of them, or a row cut on some and
blank on the rest. A row with no cells at all shows the empty default.
"""
- values: Set[str] = {select(self.cells[generator]) for generator in self.subcolumn_generators}
+ values: Set[str] = {select(self.cells[channel]) for channel in self.subcolumn_channels}
return aggregate_labels(values, default=default)
diff --git a/src/sampletones_application/view_model/sequencer/voices.py b/src/sampletones_application/view_model/sequencer/voices.py
new file mode 100644
index 000000000..580c5bef0
--- /dev/null
+++ b/src/sampletones_application/view_model/sequencer/voices.py
@@ -0,0 +1,58 @@
+from enum import StrEnum
+from typing import Tuple
+
+from pydantic import BaseModel
+
+from sampletones_core.utils.display import display_voice_label
+
+
+class VoiceKind(StrEnum):
+ """Which of the two kinds a voice list entry carries.
+
+ A sample stands on a recording it was converted from; an instrument was written by hand. The list
+ marks each so a reader tells them apart, and the gestures a row offers follow from it.
+ """
+
+ SAMPLE = "sample"
+ INSTRUMENT = "instrument"
+
+ @property
+ def places_across_channels(self) -> bool:
+ """Whether the tracker's sample column can place a voice of this kind.
+
+ The column writes a voice to every channel it covers and clears the rest, which a recording
+ states for itself. A hand-written instrument sounds wherever its envelopes make a frame, so
+ the channel it plays on is the reader's to name and it is placed in a channel column.
+ """
+ return self is VoiceKind.SAMPLE
+
+
+class VoiceEntryViewModel(BaseModel, frozen=True):
+ voice_id: str
+ name: str
+ kind: VoiceKind
+
+
+class VoiceSelection(BaseModel, frozen=True):
+ """The voices panel's selected row, offered to the sequencer as an operation target.
+
+ Carries the voice's identity for acting on it and its position for naming it, so an
+ operation reached from elsewhere in the tab — the browser's replace item — addresses the
+ selection the same way the voices panel displays it.
+ """
+
+ voice_id: str
+ position: int
+ name: str
+ kind: VoiceKind
+
+ @property
+ def label(self) -> str:
+ """The voice's list label, matching how the voices panel and tracker name it."""
+ return display_voice_label(self.position, self.name)
+
+
+class SequencerVoicesViewModel(BaseModel, frozen=True):
+ """The ordered voice pool shown in the right-hand voices panel."""
+
+ voices: Tuple[VoiceEntryViewModel, ...]
diff --git a/src/sampletones_application/view_model/shared/export.py b/src/sampletones_application/view_model/shared/export.py
new file mode 100644
index 000000000..92fe560c7
--- /dev/null
+++ b/src/sampletones_application/view_model/shared/export.py
@@ -0,0 +1,97 @@
+from __future__ import annotations
+
+from enum import StrEnum
+from typing import Final, FrozenSet, Optional, Tuple
+
+from pydantic import BaseModel
+
+from sampletones_application.view_model.shared.percent import format_percent
+from sampletones_core.exports.stage import ExportStage
+
+NO_PROGRESS: Final[float] = 0.0
+NOTHING_MEASURED: Final[int] = 0
+
+
+class ExportPhase(StrEnum):
+ IDLE = "idle"
+ EXPORTING = "exporting"
+ CANCELLING = "cancelling"
+
+
+ACTIVE_PHASES: Final[FrozenSet[ExportPhase]] = frozenset(
+ {
+ ExportPhase.EXPORTING,
+ ExportPhase.CANCELLING,
+ }
+)
+
+
+class SongExportViewModel(BaseModel, frozen=True):
+ """What the export dialog draws: the stages a run has reached, and where the latest one is.
+
+ A run's shape is its format's own — a tracker instrument is written and done with, while a
+ program is played out, compressed and then written — so the stages are listed as they are
+ reached rather than laid out in advance. The one at the end of the list is the one under way,
+ and the stages before it are behind.
+
+ Attributes:
+ phase: Where the run stands, from the dialog opening to the outcome that closes it.
+ stages: The stages the run has reached, in the order it reached them.
+ figure: What the stage under way has covered, in the words its own unit is stated in.
+ progress: How far the stage under way has got, from 0 to 1, where it travels to an end.
+ traveling: Whether the stage under way arrives at what it is measured against.
+ """
+
+ phase: ExportPhase
+ stages: Tuple[ExportStage, ...]
+ figure: str
+ progress: float
+ traveling: bool
+
+ @classmethod
+ def idle(cls) -> SongExportViewModel:
+ """The dialog with no run behind it, which is what the window opens on."""
+ return cls(
+ phase=ExportPhase.IDLE,
+ stages=(),
+ figure="",
+ progress=NO_PROGRESS,
+ traveling=False,
+ )
+
+ @property
+ def is_active(self) -> bool:
+ return self.phase in ACTIVE_PHASES
+
+ @property
+ def stage(self) -> Optional[ExportStage]:
+ """The stage under way, and ``None`` before the run names its first."""
+ return self.stages[-1] if self.stages else None
+
+ @property
+ def progress_visible(self) -> bool:
+ """Whether a bar stands, which a stage arriving at an end is what earns."""
+ return self.traveling
+
+ @property
+ def working_visible(self) -> bool:
+ """Whether the turning indicator stands, which is how a stage without an end reads."""
+ return not self.traveling
+
+ @property
+ def progress_overlay(self) -> str:
+ """The percentage label rendered over the progress bar, derived from the fraction."""
+ return format_percent(self.progress)
+
+ @property
+ def cancel_enabled(self) -> bool:
+ """Whether a running export still takes a stop, which one already stopping has taken."""
+ return self.phase == ExportPhase.EXPORTING
+
+ def stage_visible(self, stage: ExportStage) -> bool:
+ """Whether ``stage`` is listed, which the run reaching it is what decides."""
+ return stage in self.stages
+
+ def stage_reached(self, stage: ExportStage) -> bool:
+ """Whether ``stage`` is behind the run, which is what dims it in the list."""
+ return self.stage_visible(stage) and stage != self.stage
diff --git a/src/sampletones_application/view_model/shared/footprint.py b/src/sampletones_application/view_model/shared/footprint.py
index b8578a31f..afe0f6364 100644
--- a/src/sampletones_application/view_model/shared/footprint.py
+++ b/src/sampletones_application/view_model/shared/footprint.py
@@ -2,7 +2,7 @@
from pydantic import BaseModel
-from sampletones_core.constants.enums import GeneratorName
+from sampletones_core.constants.enums import ChannelName
from sampletones_core.formats.famitracker.footprint import (
InstrumentFootprint,
total_footprint,
@@ -10,28 +10,31 @@
class InstrumentSizeViewModel(BaseModel, frozen=True):
- """The bytes one channel's instrument occupies once a tracker compiles it.
+ """The bytes one instrument occupies once a tracker compiles it.
The measurement is carried as it was taken, both regions intact, so a display naming the
- whole and one naming a region read the same figure.
+ whole and one naming a region read the same figure. An instrument naming a channel is a
+ sample's slice of that channel; one naming none is a voice written by hand, stored once for
+ every channel that reaches it.
"""
- generator: GeneratorName
+ channel: Optional[ChannelName]
footprint: InstrumentFootprint
@property
def total_bytes(self) -> int:
- """The bytes this channel's instrument occupies, its two regions together."""
+ """The bytes this instrument occupies, its two regions together."""
return self.footprint.total_bytes
-class SampleFootprintViewModel(BaseModel, frozen=True):
- """The byte sizes a sample's instruments occupy, one entry per channel it covers.
+class VoiceFootprintViewModel(BaseModel, frozen=True):
+ """The byte sizes a voice's instruments occupy.
A sample exports one instrument per channel its reconstruction covers, so a display reads
- :attr:`total_bytes` for the sample as a whole and :meth:`bytes_for` for a single channel.
- Both the instruments panel and the samples menu read their figures from here, so the two
- name the same size for the same sample.
+ :attr:`total_bytes` for the voice as a whole and :meth:`bytes_for` for a single channel. A
+ voice written by hand exports one instrument every channel reaches, so it carries a single
+ entry and its whole figure is that instrument's. Both the instruments panel and the voices menu read their
+ figures from here, so the two name the same size for the same voice.
"""
instruments: Tuple[InstrumentSizeViewModel, ...]
@@ -39,33 +42,38 @@ class SampleFootprintViewModel(BaseModel, frozen=True):
@classmethod
def from_footprints(
cls,
- footprints: Dict[GeneratorName, InstrumentFootprint],
+ footprints: Dict[ChannelName, InstrumentFootprint],
) -> Self:
"""Collects measured channels in the generators' own order, so displays list them alike."""
return cls(
instruments=tuple(
InstrumentSizeViewModel(
- generator=generator_name,
- footprint=footprints[generator_name],
+ channel=channel_name,
+ footprint=footprints[channel_name],
)
- for generator_name in GeneratorName.items()
- if generator_name in footprints
+ for channel_name in ChannelName.items()
+ if channel_name in footprints
),
)
+ @classmethod
+ def from_instrument(cls, footprint: InstrumentFootprint) -> Self:
+ """Carries one instrument every channel reaches, which is what a hand-written voice exports."""
+ return cls(instruments=(InstrumentSizeViewModel(channel=None, footprint=footprint),))
+
@property
def total_bytes(self) -> int:
- """The bytes the whole sample occupies, its instruments summed region by region.
+ """The bytes the whole voice occupies, its instruments summed region by region.
- The sum is the measurement's own, so a sample's figure and a channel's are arrived at
+ The sum is the measurement's own, so a voice's figure and a channel's are arrived at
the same way.
"""
return total_footprint(instrument.footprint for instrument in self.instruments).total_bytes
- def bytes_for(self, generator: GeneratorName) -> Optional[int]:
+ def bytes_for(self, channel: ChannelName) -> Optional[int]:
"""The bytes one channel's instrument occupies, where the sample covers that channel."""
for instrument in self.instruments:
- if instrument.generator == generator:
+ if instrument.channel == channel:
return instrument.total_bytes
return None
diff --git a/src/sampletones_application/view_model/shared/history.py b/src/sampletones_application/view_model/shared/history.py
index 08fc07cd9..482752aa2 100644
--- a/src/sampletones_application/view_model/shared/history.py
+++ b/src/sampletones_application/view_model/shared/history.py
@@ -1,26 +1,29 @@
from enum import StrEnum
-from typing import Tuple, Union
+from typing import Tuple
from pydantic import BaseModel
class HistoryDetailRole(StrEnum):
- """The kind of data a detail segment carries, driving its colour.
+ """The kind of data a detail segment carries, driving its color.
A role is a semantic tag chosen by the logic layer; the panel maps it to a
- concrete colour, keeping the detail-producing code free of any visual
- concern.
+ concrete color, keeping the detail-producing code free of any visual
+ concern. Three of them read a voice: ``SAMPLE`` and ``INSTRUMENT`` name the
+ kind a line is about, so its position and its name wear that kind's color,
+ and ``VOICE`` carries a voice reference the kind says nothing about — the
+ tracker's voice slot, and a voice the pool has stopped holding.
"""
FRAME = "frame"
CHANNEL = "channel"
ROW = "row"
- INSTRUMENT = "instrument"
+ VOICE = "voice"
TRANSPOSE = "transpose"
VOLUME = "volume"
VALUE = "value"
SAMPLE = "sample"
- NAME = "name"
+ INSTRUMENT = "instrument"
FEATURE_VOLUME = "feature_volume"
FEATURE_ARPEGGIO = "feature_arpeggio"
FEATURE_PITCH = "feature_pitch"
@@ -28,30 +31,11 @@ class HistoryDetailRole(StrEnum):
SEPARATOR = "separator"
-class HistoryDetailWord(StrEnum):
- """A language-managed detail token, stored by key and translated at render time.
-
- Values mirror the ``SequencerHistoryElements`` members carrying the words, so
- a coordinator resolves a token exactly the way it resolves an entry's action
- label — committed entries stay language-independent.
- """
-
- LOOP_ON = "loop_on"
- LOOP_OFF = "loop_off"
-
-
class HistoryDetailSegment(BaseModel, frozen=True):
- """One coloured token of a history entry's detail line."""
+ """One colored token of a history entry's detail line."""
text: str
role: HistoryDetailRole
-class HistoryDetailWordSegment(BaseModel, frozen=True):
- """One coloured token whose text is looked up from the language manager when rendered."""
-
- word: HistoryDetailWord
- role: HistoryDetailRole
-
-
-HistoryDetail = Tuple[Union[HistoryDetailSegment, HistoryDetailWordSegment], ...]
+HistoryDetail = Tuple[HistoryDetailSegment, ...]
diff --git a/src/sampletones_application/view_model/shared/project_properties.py b/src/sampletones_application/view_model/shared/project_properties.py
index a223175fb..80b16403b 100644
--- a/src/sampletones_application/view_model/shared/project_properties.py
+++ b/src/sampletones_application/view_model/shared/project_properties.py
@@ -7,7 +7,7 @@
class ProjectPropertiesViewModel(BaseModel, frozen=True):
- """The project info and metre the properties dialog renders and offers for editing."""
+ """The project info and meter the properties dialog renders and offers for editing."""
title: str
author: str
diff --git a/src/sampletones_application/view_model/shared/render.py b/src/sampletones_application/view_model/shared/render.py
index 5e8928c7d..926e145ae 100644
--- a/src/sampletones_application/view_model/shared/render.py
+++ b/src/sampletones_application/view_model/shared/render.py
@@ -28,7 +28,7 @@ class RenderPhase(StrEnum):
RENDERING = "rendering"
CANCELLING = "cancelling"
COMPLETED = "completed"
- CANCELLED = "cancelled"
+ CANCELED = "canceled"
FAILED = "failed"
diff --git a/src/sampletones_application/view_model/shared/stems.py b/src/sampletones_application/view_model/shared/stems.py
new file mode 100644
index 000000000..eba1f7b37
--- /dev/null
+++ b/src/sampletones_application/view_model/shared/stems.py
@@ -0,0 +1,97 @@
+from pathlib import Path
+from typing import FrozenSet, Tuple
+
+from pydantic import BaseModel
+
+from sampletones_core.constants.enums import ChannelName
+
+
+class StemRowViewModel(BaseModel, frozen=True):
+ """One recording in a stems list, as the list renders it.
+
+ A row states where it stands — the level it picks on, the place it takes among the
+ recordings sharing that level, and how many of each the list holds — so the moves a list
+ offers gray themselves out from the row alone. ``key`` is the identity the list reports a
+ gesture under: the recording's path where the list gathers files, the stem id where it
+ describes a recorded assignment. ``offered_channels`` names the boxes the row draws and
+ ``channels`` the ones ticked among them.
+ """
+
+ key: str
+ path: Path
+ channels: FrozenSet[ChannelName]
+ offered_channels: FrozenSet[ChannelName]
+ available: bool
+ level: int
+ position: int
+ level_size: int
+ level_count: int
+
+ @property
+ def name(self) -> str:
+ """The recording's own name, which is what the row reads as."""
+ return self.path.stem
+
+ @property
+ def takes_part(self) -> bool:
+ """The recording holds a channel, so the list counts it in."""
+ return bool(self.channels)
+
+ @property
+ def offers_channels(self) -> bool:
+ """The row draws at least one box, so there is a channel to give the recording."""
+ return bool(self.offered_channels)
+
+ @property
+ def in_play(self) -> bool:
+ """The recording is there to be read and holds a channel, so what it carries is heard."""
+ return self.available and self.takes_part
+
+ @property
+ def is_first_on_level(self) -> bool:
+ return self.position == 0
+
+ @property
+ def is_last_on_level(self) -> bool:
+ return self.position == self.level_size - 1
+
+ @property
+ def has_level_above(self) -> bool:
+ return self.level > 0
+
+ @property
+ def has_level_below(self) -> bool:
+ return self.level < self.level_count - 1
+
+ @property
+ def alone_on_level(self) -> bool:
+ return self.level_size == 1
+
+
+class StemsListViewModel(BaseModel, frozen=True):
+ """What a stems list renders: the rows, the columns they line up in, and how they answer.
+
+ ``muted_channels`` names the columns a choice made elsewhere has switched off, which the
+ boxes report while staying as clickable as any other. ``collapse_levels`` draws every row
+ in one table, leaving the levels to the reader's memory rather than to a caption.
+ """
+
+ rows: Tuple[StemRowViewModel, ...]
+ channels_in_play: Tuple[ChannelName, ...]
+ muted_channels: FrozenSet[ChannelName]
+ live: bool
+ collapse_levels: bool
+
+ @property
+ def row_count(self) -> int:
+ return len(self.rows)
+
+ @property
+ def level_count(self) -> int:
+ """How many levels the listed recordings are spread over."""
+ return max((row.level + 1 for row in self.rows), default=0)
+
+ @property
+ def playing_count(self) -> int:
+ """How many of the listed recordings hold a channel."""
+ return sum(1 for row in self.rows if row.takes_part)
diff --git a/src/sampletones_application/view_model/shared/waveform_data.py b/src/sampletones_application/view_model/shared/waveform_data.py
index 4f70af87c..844a25de4 100644
--- a/src/sampletones_application/view_model/shared/waveform_data.py
+++ b/src/sampletones_application/view_model/shared/waveform_data.py
@@ -3,34 +3,33 @@
import numpy as np
-from sampletones_core.constants.enums import GeneratorName
+from sampletones_core.audio.mixing import align, mix
+from sampletones_core.constants.enums import ChannelName
@dataclass(frozen=True)
class WaveformData:
original_audio: Optional[np.ndarray]
approximation: np.ndarray
- approximations: Dict[GeneratorName, np.ndarray]
+ approximations: Dict[ChannelName, np.ndarray]
coefficient: float
frame_length: int
- def partials(self, generator_names: List[GeneratorName]) -> np.ndarray:
+ def partials(self, channel_names: List[ChannelName]) -> np.ndarray:
"""Sums the selected generators' approximations, silent when none apply.
The approximation sets the length, so the silent result matches the waveform even when
- no original audio is present.
+ no original audio is present. Each selected approximation pads to that length before
+ the sum, so a channel that ends early leaves the tail in silence.
"""
- if not generator_names:
+ if not channel_names:
return np.zeros_like(self.approximation)
selected_approximations = [
- self.approximations[generator_name]
- for generator_name in generator_names
- if generator_name in self.approximations
+ self.approximations[channel_name] for channel_name in channel_names if channel_name in self.approximations
]
if not selected_approximations:
return np.zeros_like(self.approximation)
- partials: np.ndarray = np.sum(selected_approximations, axis=0)
- return partials
+ return mix(align(selected_approximations, len(self.approximation)))
diff --git a/src/sampletones_application/viewport.py b/src/sampletones_application/viewport.py
index 6c740dd59..80c6d51ba 100644
--- a/src/sampletones_application/viewport.py
+++ b/src/sampletones_application/viewport.py
@@ -85,9 +85,9 @@ def monitor_area(self) -> MonitorArea:
return self._monitor_area(int(viewport_x), int(viewport_y), width, height)
def refresh_clear_color(self) -> None:
- """Paints the area around the windows in the main theme's background colour.
+ """Paints the area around the windows in the main theme's background color.
- DearPyGui holds the clear colour outside the theme system, so it is issued again
+ DearPyGui holds the clear color outside the theme system, so it is issued again
whenever the theme's background answers with a new value.
"""
color = self._theme.get_color(dpg.mvAll, dpg.mvThemeCol_WindowBg)
diff --git a/src/sampletones_assets/mark/raster.py b/src/sampletones_assets/mark/raster.py
index 7c437fe08..e7d388bd9 100644
--- a/src/sampletones_assets/mark/raster.py
+++ b/src/sampletones_assets/mark/raster.py
@@ -51,7 +51,7 @@ def render(self) -> Image.Image:
return image
def _background(self) -> Image.Image:
- """The frame: a vertical gradient between the two background colours, rounded at its corners."""
+ """The frame: a vertical gradient between the two background colors, rounded at its corners."""
size = (self.canvas, self.canvas)
top = Image.new("RGB", size, self.mark.colors.background.top)
bottom = Image.new("RGB", size, self.mark.colors.background.bottom)
diff --git a/src/sampletones_assets/mark/specification/__init__.py b/src/sampletones_assets/mark/specification/__init__.py
index fa600fe11..6e78e8710 100644
--- a/src/sampletones_assets/mark/specification/__init__.py
+++ b/src/sampletones_assets/mark/specification/__init__.py
@@ -21,7 +21,7 @@ class Mark(BaseModel, extra="forbid", frozen=True):
"""
frame: MarkFrame = Field(description="The rounded square the mark sits on.")
- colors: MarkColors = Field(description="The colours the mark is drawn in.")
+ colors: MarkColors = Field(description="The colors the mark is drawn in.")
waves: MarkWaves = Field(description="The wave crossing the frame.")
render: MarkRender = Field(description="How the mark is rasterized.")
diff --git a/src/sampletones_assets/mark/specification/colors.py b/src/sampletones_assets/mark/specification/colors.py
index 1063ea2b7..8f9820420 100644
--- a/src/sampletones_assets/mark/specification/colors.py
+++ b/src/sampletones_assets/mark/specification/colors.py
@@ -16,14 +16,14 @@ def _validate_hex_color(value: str) -> str:
class MarkBackground(BaseModel, extra="forbid", frozen=True):
"""The vertical gradient filling the frame."""
- top: HexColor = Field(description="Colour at the top edge of the frame.")
- bottom: HexColor = Field(description="Colour at the bottom edge of the frame.")
+ top: HexColor = Field(description="Color at the top edge of the frame.")
+ bottom: HexColor = Field(description="Color at the bottom edge of the frame.")
class MarkColors(BaseModel, extra="forbid", frozen=True):
- """The mark's colours, written as the hex strings the vector carries."""
+ """The mark's colors, written as the hex strings the vector carries."""
background: MarkBackground = Field(description="Gradient behind the wave.")
- sine: HexColor = Field(description="Colour of the smooth half of the wave.")
- square: HexColor = Field(description="Colour of the stepped half of the wave.")
- rim: HexColor = Field(description="Colour of the hairline inside the frame's edge.")
+ sine: HexColor = Field(description="Color of the smooth half of the wave.")
+ square: HexColor = Field(description="Color of the stepped half of the wave.")
+ rim: HexColor = Field(description="Color of the hairline inside the frame's edge.")
diff --git a/src/sampletones_config/README.md b/src/sampletones_config/README.md
index e54fd9655..767fb77f6 100644
--- a/src/sampletones_config/README.md
+++ b/src/sampletones_config/README.md
@@ -9,7 +9,7 @@ The schema that validates each file lives in the **consuming** package:
- `sampletones_application` — layout, theme, palettes, language, behavior, deployment.
- `sampletones_core` — calibration.
-- `sampletones_shared` — the loader primitives only.
+- `sampletones_shared` — the import boundaries and the loader primitives.
The data package must not import a schema, and a schema package must not inline data.
@@ -19,12 +19,13 @@ The data package must not import a schema, and a schema package must not inline
|-----------|---------|--------------|
| `application/` | Deployment-time environment knobs | `DeploymentConfig` |
| `behavior/` | Non-visual runtime behavior | `BehaviorConfig` |
+| `boundaries/` | The imports the source tree is held to | `ImportBoundaryRules` |
| `calibration/` | DSP calibration tuning | `CorpusConfig`, `RefereeConfig` |
| `keybindings/` | The key combinations each named action answers | `ShortcutScheme` |
| `lang/` | Interface strings (i18n) | `LanguageManager` |
| `layout/` | UI geometry, dimensions, fonts | `LayoutConfig` |
-| `palettes/` | The colour sets layout and theme resolve against | `Palette` |
-| `theme/` | DearPyGui theme/colour styling | `ThemeSpec` |
+| `palettes/` | The color sets layout and theme resolve against | `Palette` |
+| `theme/` | DearPyGui theme/color styling | `ThemeSpec` |
The rules for where a value belongs, how the directories nest, and how each domain is
loaded are prescriptive and documented in
diff --git a/src/sampletones_config/application/deployment.yaml b/src/sampletones_config/application/deployment.yaml
index 40e802381..fe0022b18 100644
--- a/src/sampletones_config/application/deployment.yaml
+++ b/src/sampletones_config/application/deployment.yaml
@@ -1,2 +1,2 @@
-log_level: INFO
-strict_history: false
+log_level: DEBUG
+strict_history: true
diff --git a/src/sampletones_config/boundaries/general.yaml b/src/sampletones_config/boundaries/general.yaml
new file mode 100644
index 000000000..e9ff67e9e
--- /dev/null
+++ b/src/sampletones_config/boundaries/general.yaml
@@ -0,0 +1,14 @@
+groups:
+ visual:
+ - dearpygui
+ - sampletones_application.ui
+ - sampletones_application.utils.gui
+
+ service_contracts:
+ - sampletones_application.services.result
+ - sampletones_application.services.conversion.result
+ - sampletones_application.services.export.result
+ - sampletones_application.services.regeneration.result
+ - sampletones_application.services.render.result
+ - sampletones_application.services.retune.result
+ - sampletones_application.services.song_player.result
diff --git a/src/sampletones_config/boundaries/graphs.yaml b/src/sampletones_config/boundaries/graphs.yaml
new file mode 100644
index 000000000..eda8d30e2
--- /dev/null
+++ b/src/sampletones_config/boundaries/graphs.yaml
@@ -0,0 +1,27 @@
+packages:
+ layers:
+ sampletones_shared: []
+ sampletones_config: []
+ sampletones_assets: [sampletones_shared]
+ sampletones_synthesis: [sampletones_shared]
+ sampletones_core: [sampletones_shared, sampletones_synthesis]
+ sampletones_player: [sampletones_shared, sampletones_core]
+ sampletones_application: [sampletones_shared, sampletones_core, sampletones_player]
+ sampletones: [sampletones_shared, sampletones_core, sampletones_application]
+
+player:
+ root: sampletones_player
+ package: sampletones_player
+ layers:
+ __init__.py: []
+ specification: []
+ clock: [specification]
+ registers: [specification]
+ compression: [specification, registers]
+ song.py: [clock, registers, compression]
+ builder.py: [song.py, registers, clock, compression]
+ trace: [song.py, specification]
+ nsf: [song.py, specification, compression, driver]
+ export.py: [builder.py, nsf, driver, compression]
+ driver: [specification]
+ driver/assembler: [driver, specification]
diff --git a/src/sampletones_config/boundaries/rules.yaml b/src/sampletones_config/boundaries/rules.yaml
new file mode 100644
index 000000000..80ea3ba95
--- /dev/null
+++ b/src/sampletones_config/boundaries/rules.yaml
@@ -0,0 +1,55 @@
+- root: sampletones_application
+ pattern: "config/**/*.py"
+ forbidden_groups: [visual]
+ forbidden:
+ - sampletones_application.coordinators
+ - sampletones_application.application
+
+- root: sampletones_application
+ pattern: "logic/**/*.py"
+ forbidden_groups: [visual]
+ forbidden:
+ - sampletones_application.coordinators
+ - sampletones_application.services
+ contract_groups: [service_contracts]
+
+- root: sampletones_application
+ pattern: "view_model/**/*.py"
+ forbidden_groups: [visual]
+ forbidden:
+ - sampletones_application.coordinators
+ - sampletones_application.config
+ - sampletones_application.logic
+ - sampletones_application.services
+
+- root: sampletones_application
+ pattern: "services/**/*.py"
+ forbidden_groups: [visual]
+ forbidden:
+ - sampletones_application.view_model
+ - sampletones_application.coordinators
+ - sampletones_application.config
+ - sampletones_application.logic
+
+- root: sampletones_application
+ pattern: "shell.py"
+ forbidden:
+ - sampletones_application.logic
+ - sampletones_application.services
+
+- root: sampletones_application
+ pattern: "coordinators/**/*.py"
+ forbidden:
+ - sampletones_application.application
+ - sampletones_application.shell
+
+- root: sampletones_application
+ pattern: "ui/**/*.py"
+ forbidden:
+ - sampletones_application.coordinators
+ - sampletones_application.logic
+ - sampletones_application.services
+ - sampletones_application.config
+ - sampletones_application.application
+ - sampletones_application.shell
+ - sampletones_application.utils.gui.dialogs
diff --git a/src/sampletones_config/boundaries/tokens.yaml b/src/sampletones_config/boundaries/tokens.yaml
new file mode 100644
index 000000000..f144f828a
--- /dev/null
+++ b/src/sampletones_config/boundaries/tokens.yaml
@@ -0,0 +1,21 @@
+- root: sampletones_application
+ pattern: "ui/panels/**/*.py"
+ forbidden: '\bSUF_PANEL_'
+ message: >-
+ ui/panels must not reference a column suffix (SUF_PANEL_*); a panel receives its
+ parent through create_panel(parent), set by the coordinator that owns the layout
+
+- root: sampletones_application
+ pattern: "ui/panels/**/*.py"
+ forbidden: 'parent\s*=\s*TAG_SEQUENCER_TRACKER_PANEL\b'
+ message: >-
+ ui/panels must not parent into another panel's container (TAG_SEQUENCER_TRACKER_PANEL);
+ the coordinator injects the parent through create_panel(parent)
+
+- root: sampletones_application
+ pattern: "ui/panels/**/*.py"
+ forbidden: '\bTAG_GLOBAL_THEME_PANEL_(SURFACE|GROUND)\b'
+ message: >-
+ ui/panels must not bind a structural depth theme (TAG_GLOBAL_THEME_PANEL_SURFACE/GROUND);
+ only the layout primitives own depth (TabColumns binds the column, card() binds the card),
+ and a panel binds only semantic themes
diff --git a/src/sampletones_config/keybindings/default.yaml b/src/sampletones_config/keybindings/default.yaml
index e373d87a1..94e754d36 100644
--- a/src/sampletones_config/keybindings/default.yaml
+++ b/src/sampletones_config/keybindings/default.yaml
@@ -9,6 +9,7 @@ bindings:
ProjectProperties: {combination: "Alt+P"}
ExportProjectFamiTracker: {combination: "Ctrl+M"}
ExportProjectBitphase: {combination: "Ctrl+B"}
+ ExportProjectNSF: {combination: ~}
RenderSong: {combination: "Ctrl+Shift+E"}
CloseProject: {combination: "Ctrl+W"}
Exit: {combination: "Alt+F4"}
@@ -29,10 +30,16 @@ bindings:
ExportReconstructionWav: {combination: "Ctrl+E"}
ExportInstrumentsFamiTracker: {combination: "Ctrl+I"}
ExportInstrumentsBitphasePreset: {combination: ~}
+ ExportInstrumentsNSF: {combination: ~}
AddReconstructionToSequencer: {combination: ~}
OpenReconstructionInExplorer: {combination: ~}
LocateOriginalAudio: {combination: ~}
+ # voice
+ NewInstrument: {combination: ~}
+ AddSampleFromFile: {combination: ~}
+ ImportInstrument: {combination: ~}
+
# playback
Play: {combination: "Space"}
PlayFromStart: {combination: "Shift+Space"}
@@ -133,14 +140,14 @@ bindings:
TrackerCancelEntry: {combination: "Esc"}
TrackerPlayFromRow: {combination: "Ctrl+Shift+Space"}
- # samples
- SamplesRenameSample: {combination: "F2"}
- SamplesRemoveSample: {combination: "Del"}
- SamplesMoveSampleUp: {combination: "Alt+Up"}
- SamplesMoveSampleDown: {combination: "Alt+Down"}
- SamplesMoveSampleToTop: {combination: "Alt+Home"}
- SamplesMoveSampleToBottom: {combination: "Alt+End"}
- SamplesCancelRename: {combination: "Esc"}
+ # voices
+ VoicesRenameVoice: {combination: "F2"}
+ VoicesRemoveVoice: {combination: "Del"}
+ VoicesMoveVoiceUp: {combination: "Alt+Up"}
+ VoicesMoveVoiceDown: {combination: "Alt+Down"}
+ VoicesMoveVoiceToTop: {combination: "Alt+Home"}
+ VoicesMoveVoiceToBottom: {combination: "Alt+End"}
+ VoicesCancelRename: {combination: "Esc"}
# dialogs
DialogNextControl: {combination: "Tab"}
diff --git a/src/sampletones_config/keybindings/macos.yaml b/src/sampletones_config/keybindings/macos.yaml
index 207731372..36f6fcdaa 100644
--- a/src/sampletones_config/keybindings/macos.yaml
+++ b/src/sampletones_config/keybindings/macos.yaml
@@ -9,6 +9,7 @@ bindings:
ProjectProperties: {combination: "Cmd+Alt+P"}
ExportProjectFamiTracker: {combination: "Cmd+M"}
ExportProjectBitphase: {combination: "Cmd+B"}
+ ExportProjectNSF: {combination: ~}
RenderSong: {combination: "Cmd+Shift+E"}
CloseProject: {combination: "Cmd+W"}
Exit: {combination: "Cmd+Q"}
@@ -29,10 +30,16 @@ bindings:
ExportReconstructionWav: {combination: "Cmd+E"}
ExportInstrumentsFamiTracker: {combination: "Cmd+I"}
ExportInstrumentsBitphasePreset: {combination: ~}
+ ExportInstrumentsNSF: {combination: ~}
AddReconstructionToSequencer: {combination: ~}
OpenReconstructionInExplorer: {combination: ~}
LocateOriginalAudio: {combination: ~}
+ # voice
+ NewInstrument: {combination: ~}
+ AddSampleFromFile: {combination: ~}
+ ImportInstrument: {combination: ~}
+
# playback
Play: {combination: "Space"}
PlayFromStart: {combination: "Shift+Space"}
@@ -133,14 +140,14 @@ bindings:
TrackerCancelEntry: {combination: "Esc"}
TrackerPlayFromRow: {combination: "Ctrl+Shift+Space"}
- # samples
- SamplesRenameSample: {combination: "F2"}
- SamplesRemoveSample: {combination: "Del", aliases: ["Cmd+Backspace"]}
- SamplesMoveSampleUp: {combination: "Alt+Up"}
- SamplesMoveSampleDown: {combination: "Alt+Down"}
- SamplesMoveSampleToTop: {combination: "Alt+Home", aliases: ["Cmd+Alt+Up"]}
- SamplesMoveSampleToBottom: {combination: "Alt+End", aliases: ["Cmd+Alt+Down"]}
- SamplesCancelRename: {combination: "Esc"}
+ # voices
+ VoicesRenameVoice: {combination: "F2"}
+ VoicesRemoveVoice: {combination: "Del", aliases: ["Cmd+Backspace"]}
+ VoicesMoveVoiceUp: {combination: "Alt+Up"}
+ VoicesMoveVoiceDown: {combination: "Alt+Down"}
+ VoicesMoveVoiceToTop: {combination: "Alt+Home", aliases: ["Cmd+Alt+Up"]}
+ VoicesMoveVoiceToBottom: {combination: "Alt+End", aliases: ["Cmd+Alt+Down"]}
+ VoicesCancelRename: {combination: "Esc"}
# dialogs
DialogNextControl: {combination: "Tab"}
diff --git a/src/sampletones_config/lang/en.yaml b/src/sampletones_config/lang/en.yaml
index 8c1dc25e1..19772cb6a 100644
--- a/src/sampletones_config/lang/en.yaml
+++ b/src/sampletones_config/lang/en.yaml
@@ -35,12 +35,13 @@ 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.export_bitphase_project: "Export Bitphase project"
+global.dialog.title.export_nsf_project: "Export NSF program"
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"
global.dialog.title.no_project_open: "No project open"
-global.dialog.title.remove_sample: "Remove sample"
+global.dialog.title.remove_voice: "Remove voice"
global.dialog.title.change_nes_frequency: "Change NES frequency"
global.dialog.title.frequency_mismatch: "Different NES frequency"
global.dialog.title.about: "About"
@@ -52,6 +53,7 @@ global.dialog.filter.module: "FamiTracker module"
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.nsf: "NES sound file"
global.dialog.filter.config: "Configuration files"
global.dialog.filter.audio: "Audio files"
global.dialog.filter.wave: "WAV audio"
@@ -76,6 +78,8 @@ global.dialog.message.project_exported_successfully: "FamiTracker module exporte
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.nsf_project_exported_successfully: "NSF program exported successfully."
+global.dialog.message.nsf_project_export_failed: "Failed to export NSF program."
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?"
@@ -92,7 +96,7 @@ global.dialog.message.configuration_recovery_list_header: "The following setting
global.dialog.message.configuration_recovery_path_prefix: "You can edit the configuration file directly:"
global.dialog.message.audio_playback_error: "Audio playback error"
global.dialog.message.no_project_open: "No project is open. Create or open a project before adding a reconstruction."
-global.dialog.message.remove_sample: "The sample \"{name}\" is used by one or more patterns. Removing it will clear every row that references it. Remove it anyway?"
+global.dialog.message.remove_voice: "The voice \"{name}\" is used by one or more patterns. Removing it will clear every row that references it. Remove it anyway?"
global.dialog.message.change_nes_frequency: "Changing the NES frequency leaves the loaded reconstructions out of sync with the project rate for editing in the Reconstructions tab. Song playback already follows the new rate. Retune all samples to match?"
global.dialog.message.frequency_mismatch: "This reconstruction was generated at {reconstruction} Hz, but the project runs at {project} Hz, so it won't play back as intended. Add it anyway?"
global.dialog.message.operation_in_progress: "An operation is in progress. Please wait until the running operation finishes."
@@ -156,12 +160,13 @@ global.context.label.add_to_sequencer: "Add to Sequencer"
global.context.template.replace_sample: "Replace {sample}"
global.context.label.locate_original_audio: "Locate original audio"
global.context.label.triangle: "Triangle"
+global.context.label.pulse: "Pulse"
global.context.label.pulse_1: "Pulse 1"
global.context.label.pulse_2: "Pulse 2"
global.context.label.noise: "Noise"
global.context.label.detail_sample_rate: "Sample rate"
global.context.label.detail_nes_frequency: "NES frequency"
-global.context.label.detail_generators: "Generators"
+global.context.label.detail_channels: "Channels"
global.context.label.detail_spectrum_method: "Generation method"
global.context.label.detail_transformation_gamma: "Transformation gamma"
global.context.label.detail_window_size: "Window size"
@@ -184,6 +189,7 @@ global.menu.label.item_file_project_properties: "Project properties..."
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_export_nsf: "NSF program..."
global.menu.label.item_file_render_song: "Render song..."
global.menu.label.item_file_close_project: "Close project"
global.menu.label.item_file_exit: "Exit"
@@ -203,6 +209,8 @@ global.menu.label.item_reconstruction_export_wav: "Export to WAV..."
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.item_reconstruction_export_instruments_nsf: "NSF program..."
+global.menu.label.group_voice: "Voice"
global.menu.label.group_playback: "Playback"
global.menu.label.item_playback_play: "Play"
global.menu.label.item_playback_pause: "Pause"
@@ -253,6 +261,22 @@ global.status.message.node_group: "Click to {expand_or_collapse} this group. Rig
global.status.message.node_sample: "Click to {expand_or_collapse} the reconstructions of this sample. Right-click to open context menu."
global.status.message.retuning_samples: "Retuning samples..."
+# =============================================================================
+# Global — Stems
+# =============================================================================
+global.stems.template.level_caption: "Level {}"
+global.stems.label.remove: "x"
+global.stems.message.drag_tooltip: "Drag onto another row to share its level, or onto a gap to start a new level."
+global.stems.message.inert_tooltip: "Tick a channel to use this recording."
+global.stems.message.missing_tooltip: "This recording is missing from disk."
+global.stems.message.unoffered_tooltip: "This recording holds no frames."
+global.stems.message.status_row_drag: "Drag {name} onto another row or a gap to move it, or right-click for more actions."
+global.stems.message.status_row_reveal: "Show {name} in the file browser."
+global.stems.message.status_master: "Turn every channel on or off for {name}."
+global.stems.message.status_channel: "Turn the {channel} channel on or off for {name}."
+global.stems.message.status_channel_muted: "The {channel} channel is off for everything, so {name} stays quiet on it."
+global.stems.message.status_remove: "Remove {name} from the list."
+
# =============================================================================
# Global — Player
# =============================================================================
@@ -287,6 +311,8 @@ main.explorer.label.context_load_reconstruction: "Load reconstruction"
main.explorer.label.context_load_library: "Load instructions library"
main.explorer.label.context_reconstruct_file: "Reconstruct file"
main.explorer.label.context_reconstruct_directory: "Reconstruct directory"
+main.explorer.label.context_add_stem: "Add as stem"
+main.explorer.label.context_add_folder_stems: "Add folder as stems"
main.explorer.label.context_set_library_directory: "Set as instructions library directory"
main.explorer.label.context_set_output_directory: "Set as output directory"
main.explorer.message.status_node_audio_no_autoplay: "Double-click to reconstruct audio. Right-click to open context menu."
@@ -312,7 +338,7 @@ main.config.tooltip.tooltip_nes_frequency: "Set the NES refresh rate (in Hz) for
# =============================================================================
# Main tab — Reconstructor panel
# =============================================================================
-main.reconstructor.label.section_generators: "Generators"
+main.reconstructor.label.section_channels: "Channels"
main.reconstructor.label.section_settings: "Reconstructor settings"
main.reconstructor.label.slider_drive: "Drive"
main.reconstructor.tooltip.tooltip_drive: "Amplify NES audio during instruction selection and output.\nAt 1.0 amplitudes are calibrated, higher values push the selection harder, introducing a distortion-like effect."
@@ -332,12 +358,16 @@ main.converter.label.convert_directory_button: "Convert directory"
main.converter.message.status_error: "Reconstruction failed."
main.converter.message.status_reconstruction_completed: "Reconstruction completed!"
main.converter.message.status_no_files: "No WAV files found to process."
-main.converter.message.status_no_generators: "No generators are enabled. Enable at least one generator to reconstruct."
+main.converter.message.status_no_channels: "No channels are enabled. Enable at least one channel to reconstruct."
main.converter.message.status_idle: "No tasks in progress."
main.converter.message.status_waiting: "Waiting to start..."
main.converter.message.status_generating_library: "Generating instructions library... (this may take a while)"
main.converter.message.status_cancelling: "Aborting the conversion..."
-main.converter.message.status_cancelled: "Conversion cancelled."
+main.converter.message.stage_loading: "reading the recordings"
+main.converter.message.stage_matching: "matching frames"
+main.converter.message.stage_decoding: "reading the channels"
+main.converter.message.stage_rendering: "rendering the frames"
+main.converter.message.status_canceled: "Conversion canceled."
main.converter.message.status_input_label: "Input:"
main.converter.message.status_output_label: "Output:"
main.converter.message.status_empty_hint: "Select a WAV file or a folder in the browser to begin."
@@ -350,7 +380,37 @@ main.converter.message.load_file_prompt: "The reconstruction is ready. Load it n
main.converter.message.load_directory_prompt: "The reconstructions are ready. Open the Reconstruction tab?"
main.converter.message.cancel_prompt: "Stop the current reconstruction?"
main.converter.template.progress_template: "Progress: {}/{} files"
+main.converter.template.stage_template: " — {stage} {completed}/{total}"
+main.converter.template.single_progress_template: "Reconstructing {}..."
main.converter.template.convert_label_template: "{}: {}"
+main.converter.label.stems_mode: "Stems mode"
+main.converter.label.channel_cap: "Channels per source"
+main.converter.label.hierarchy_mode: "Order"
+main.converter.label.hierarchy_round_robin: "Round robin"
+main.converter.label.hierarchy_strict: "Strict"
+main.converter.label.convert_stems_button: "Convert stems"
+main.converter.label.discard_stems_button: "Keep the first"
+main.converter.label.keep_stems_button: "Stay in stems mode"
+main.converter.label.add_stems_button: "Add"
+main.converter.label.overwrite_target_button: "Convert anyway"
+main.converter.message.stems_mode_tooltip: "Mix several recordings into one reconstruction, each holding the channels you give it."
+main.converter.message.channel_cap_tooltip: "How many channels one recording may hold in a single frame."
+main.converter.message.hierarchy_mode_tooltip: "Round robin gives every level a turn each round; strict fills a level before the next one picks."
+main.converter.message.stems_empty_hint: "Click recordings in the browser to gather the sources of one reconstruction."
+main.converter.message.discard_stems_prompt: "Leaving stems mode keeps the first recording and drops the rest. Continue?"
+main.converter.message.overwrite_target_prompt: "A reconstruction of this name already stands here. Converting writes over it."
+main.converter.message.stem_selection_prompt: "Pick the recordings to add."
+main.converter.message.status_stems_mode: "Mix several recordings into one reconstruction."
+main.converter.title.discard_stems_dialog: "Leave stems mode?"
+main.converter.title.overwrite_target_dialog: "Write over it?"
+main.converter.title.stem_selection_dialog: "Add recordings"
+main.converter.template.stem_selection_limit: "Room for {} more of the {} recordings found."
+main.converter.label.context_move_up: "Move up"
+main.converter.label.context_move_down: "Move down"
+main.converter.label.context_join_above: "Join the level above"
+main.converter.label.context_join_below: "Join the level below"
+main.converter.label.context_isolate: "Put on its own level"
+main.converter.label.context_remove_stem: "Remove from the conversion"
# =============================================================================
# Main tab — Advanced
@@ -362,7 +422,7 @@ main.advanced.label.select_library_directory: "Select instruction library direct
main.advanced.label.combo_spectrum_method: "Method"
main.advanced.label.slider_transformation_gamma: "Feature scaling"
main.advanced.label.input_max_workers: "Workers"
-main.advanced.tooltip.tooltip_spectrum_method: "Chooses how the sample is analysed into a spectrum:\n • FFT uses evenly spaced frequency bins\n • Log-spaced FFT regroups those bins onto a logarithmic frequency axis\n • CQT spaces bins per musical octave"
+main.advanced.tooltip.tooltip_spectrum_method: "Chooses how the sample is analyzed into a spectrum:\n • FFT uses evenly spaced frequency bins\n • Log-spaced FFT regroups those bins onto a logarithmic frequency axis\n • CQT spaces bins per musical octave"
main.advanced.tooltip.tooltip_transformation_gamma: "Scales spectral magnitudes before matching:\n • at 0 the raw linear spectrum is used\n • at 100 a logarithmic scaling is applied\n • and values in between blend the two"
main.advanced.tooltip.tooltip_max_workers: "Set the number parallel workers for audio processing tasks."
main.advanced.title.select_library_directory: "Select library directory"
@@ -397,7 +457,7 @@ reconstructions.browser.template.incompatible_version_template: "Incompatible re
reconstructions.reconstruction.label.audio_source_label: "Source"
reconstructions.reconstruction.label.autoscale_checkbox: "Autoscale"
reconstructions.reconstruction.label.reconstruction_file_label: "Reconstruction file:"
-reconstructions.reconstruction.label.original_audio_label: "Original audio:"
+reconstructions.reconstruction.label.nes_frequency_label: "NES frequency:"
reconstructions.reconstruction.label.path_not_found: "not found"
reconstructions.reconstruction.label.path_not_applicable: "N/A"
reconstructions.reconstruction.label.original_audio_radio: "Original audio"
@@ -407,6 +467,16 @@ reconstructions.reconstruction.tooltip.autoscale_tooltip: "Include reconstructio
reconstructions.reconstruction.message.locate_audio_failed: "Original audio file could not be found."
reconstructions.reconstruction.message.export_wav_success: "Reconstruction rendered successfully."
reconstructions.reconstruction.message.export_wav_failed: "Reconstruction failed to save."
+reconstructions.reconstruction.label.stems: "Stems"
+reconstructions.reconstruction.label.stems_empty: "This reconstruction records no source"
+reconstructions.reconstruction.label.stems_mode_round_robin: "Round robin"
+reconstructions.reconstruction.label.stems_mode_strict: "Strict"
+reconstructions.reconstruction.template.stems_setup: "Mode: {mode} · Channel cap: {cap}"
+reconstructions.reconstruction.label.collapse_levels: "Collapse levels"
+reconstructions.reconstruction.message.collapse_levels_tooltip: "Draw every recording in one table."
+reconstructions.reconstruction.message.status_collapse_levels: "Draw the recordings in one table or under their levels."
+reconstructions.reconstruction.title.remove_stem_dialog: "Remove recording"
+reconstructions.reconstruction.message.remove_stem_message: "Remove this recording from the reconstruction? Its frames fall silent."
# =============================================================================
# Reconstructions tab — Instruments
@@ -421,15 +491,17 @@ reconstructions.instruments.label.arpeggio_label: "Arpeggio"
reconstructions.instruments.label.duty_cycle_label: "Duty cycle"
reconstructions.instruments.label.initial_period: "Initial period:"
reconstructions.instruments.label.initial_pitch: "Initial pitch: "
+reconstructions.instruments.label.audition: "Audition: "
reconstructions.instruments.message.status_input_pitch: "Ctrl + click to type value. Enter note name (e.g. \"C-4\") or MIDI value (72)."
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_audition: "Click to choose the generator the note keys sound this instrument on."
reconstructions.instruments.message.status_bar: "Click to change {instrument_feature}. Scroll to zoom horizontally. Right-click for more options."
-reconstructions.instruments.message.status_sequence: "Edit and press Enter to change {instrument_feature}."
+reconstructions.instruments.message.status_sequence: "Type whole numbers to change {instrument_feature}, and \"|\" before the item it repeats from. Press Enter to apply."
reconstructions.instruments.message.status_sequence_too_long: "{instrument_feature}: {items} items, 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: "Writes the {generator} generator's instrument, for the tracker the chosen extension names."
+reconstructions.instruments.message.status_channel_toggle: "Click to turn {on_or_off} {channel_name}."
+reconstructions.instruments.message.status_channel_not_available: "{channel_name} is not available."
+reconstructions.instruments.message.status_export_instrument: "Writes the {channel} channel'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."
@@ -441,6 +513,9 @@ reconstructions.instruments.title.not_loaded_dialog: "Reconstruction not loaded"
reconstructions.instruments.title.export_wav_dialog: "Export WAV"
reconstructions.instruments.title.export_instrument_dialog: "Export instrument"
reconstructions.instruments.title.export_instruments_dialog: "Export instruments"
+reconstructions.instruments.tooltip.pitch_bend: "Each item shifts the note's divider by one step. What a step is worth follows the note: under a cent at the lowest notes, widening to a whole semitone at the highest."
+reconstructions.instruments.tooltip.hi_pitch_bend: "Each item shifts the note's divider by sixteen steps, so one item spans about a semitone in the middle register. It reaches further than the pitch dimension and lands more coarsely."
+reconstructions.instruments.tooltip.audition: "Play this instrument with the note keys: the bottom row starts at the tracker's octave and the top row an octave above it."
reconstructions.instruments.template.initial_pitch_tooltip_template: "Enter the initial {} by name (e.g. {}) or value ({})."
# =============================================================================
@@ -463,6 +538,8 @@ sequencer.module.label.speed: "Speed"
# Sequencer tab — Tracker
# =============================================================================
sequencer.tracker.label.tracker_text: "Tracker"
+sequencer.tracker.label.octave: "Octave"
+sequencer.tracker.tooltip.octave: "The octave a note key types at"
sequencer.tracker.label.column_row: "Row"
sequencer.tracker.label.column_sample: "Sample"
sequencer.tracker.label.column_pulse_1: "Pulse 1"
@@ -475,8 +552,8 @@ sequencer.tracker.label.context_select_all: "Select all"
sequencer.tracker.label.context_select_column: "Select column"
sequencer.tracker.label.context_select_subcolumn: "Select subcolumn"
sequencer.tracker.label.context_note_off: "Note off"
-sequencer.tracker.label.context_set_instrument: "Set instrument"
-sequencer.tracker.label.context_no_samples: "No samples"
+sequencer.tracker.label.context_set_voice: "Set voice"
+sequencer.tracker.label.context_no_voices: "No voices"
sequencer.tracker.label.context_clear_subcolumn: "Clear subcolumn"
sequencer.tracker.label.context_clear_cell: "Clear cell"
sequencer.tracker.label.context_clear_row: "Clear row"
@@ -528,20 +605,40 @@ sequencer.order.tooltip.label_channel: "Click to mute or unmute this channel.\n{
sequencer.order.tooltip.label_master: "Click to mute every channel, or to bring them all back.\nRight-click for channel actions."
# =============================================================================
-# Sequencer tab — Instruments
-# =============================================================================
-sequencer.instruments.label.instruments_text: "Samples"
-sequencer.instruments.label.column_id: "ID"
-sequencer.instruments.label.column_name: "Name"
-sequencer.instruments.label.column_loop: "Loop"
-sequencer.instruments.label.context_edit: "Edit"
-sequencer.instruments.label.context_rename: "Rename"
-sequencer.instruments.label.context_duplicate: "Duplicate"
-sequencer.instruments.label.context_remove: "Remove"
-sequencer.instruments.label.context_move_up: "Move up"
-sequencer.instruments.label.context_move_down: "Move down"
-sequencer.instruments.label.context_move_top: "Move to top"
-sequencer.instruments.label.context_move_bottom: "Move to bottom"
+# Sequencer tab — Voices
+# =============================================================================
+sequencer.voices.label.voices_text: "Voices"
+sequencer.voices.label.new_instrument: "New instrument"
+sequencer.voices.label.add_sample: "Add sample from file..."
+sequencer.voices.label.import_instrument: "Import instrument..."
+sequencer.voices.label.column_kind: "Kind"
+sequencer.voices.label.column_id: "ID"
+sequencer.voices.label.column_name: "Name"
+sequencer.voices.label.context_edit: "Edit"
+sequencer.voices.label.context_rename: "Rename"
+sequencer.voices.label.context_duplicate: "Duplicate"
+sequencer.voices.label.context_remove: "Remove"
+sequencer.voices.label.context_move_up: "Move up"
+sequencer.voices.label.context_move_down: "Move down"
+sequencer.voices.label.context_move_top: "Move to top"
+sequencer.voices.label.context_move_bottom: "Move to bottom"
+sequencer.voices.label.context_instrument_from: "New instrument from"
+sequencer.voices.label.context_export_instrument: "Export instrument..."
+sequencer.voices.label.omission_cumulative_bend: "a bend the tracker piles up, since its arpeggio stops pinning the note"
+sequencer.voices.label.omission_release_point: "a release point"
+sequencer.voices.label.omission_arpeggio_mode: "an arpeggio in fixed, relative or scheme mode"
+sequencer.voices.tooltip.new_instrument: "Add an instrument written by hand, playable on any channel"
+sequencer.voices.tooltip.kind_sample: "Sample"
+sequencer.voices.tooltip.kind_instrument: "Instrument"
+sequencer.voices.message.instrument_not_found: "The instrument file could not be found."
+sequencer.voices.title.add_sample_dialog: "Add sample"
+sequencer.voices.title.import_instrument_dialog: "Import instrument"
+sequencer.voices.title.instrument_imported: "Instrument imported"
+sequencer.voices.template.instrument_name: "Instrument {position}"
+sequencer.voices.template.status_sample: "{name} is a sample playing {channels}. It takes {bytes} as FamiTracker instruments."
+sequencer.voices.template.status_instrument: "{name} is an instrument every channel can play. It takes {bytes} as a FamiTracker instrument."
+sequencer.voices.template.status_channel_separator: ", "
+sequencer.voices.template.instrument_omissions: "\"{name}\" plays every envelope the file states.\nThe file also holds, on the instrument's own terms:"
sequencer.history.label.history_text: "History"
sequencer.history.label.undo: "Undo"
@@ -567,14 +664,13 @@ sequencer.history.label.clear_frame: "Clear frame"
sequencer.history.label.move_frame: "Move frame"
sequencer.history.label.set_order_entry: "Set order entry"
sequencer.history.label.add_sample: "Add sample"
-sequencer.history.label.remove_sample: "Remove sample"
+sequencer.history.label.add_instrument: "Add instrument"
sequencer.history.label.replace_sample: "Replace sample"
-sequencer.history.label.rename_sample: "Rename sample"
-sequencer.history.label.move_sample: "Move sample"
-sequencer.history.label.duplicate_sample: "Duplicate sample"
-sequencer.history.label.set_sample_loop: "Toggle sample loop"
-sequencer.history.label.loop_on: "on"
-sequencer.history.label.loop_off: "off"
+sequencer.history.label.edit_instrument: "Edit instrument"
+sequencer.history.label.rename_voice: "Rename voice"
+sequencer.history.label.move_voice: "Move voice"
+sequencer.history.label.duplicate_voice: "Duplicate voice"
+sequencer.history.label.remove_voice: "Remove voice"
sequencer.history.label.set_tempo: "Set tempo"
sequencer.history.label.set_speed: "Set speed"
sequencer.history.label.set_nes_frequency: "Set NES frequency"
@@ -604,7 +700,7 @@ instructions.library.message.status_generating: "Generating library..."
instructions.library.message.status_saving: "Saving generated library..."
instructions.library.message.status_generation_success: "Library generated successfully."
instructions.library.message.status_window_not_available: "Window not available."
-instructions.library.message.status_generation_cancelled: "Library generation cancelled."
+instructions.library.message.status_generation_canceled: "Library generation canceled."
instructions.library.message.status_generation_failed: "Error generating library."
instructions.library.message.status_file_not_found: "The library file could not be found."
instructions.library.message.status_file_load_error: "Error while loading the library file."
@@ -719,16 +815,22 @@ settings.render.template.bitrate: "{bitrate} kbps"
settings.render.message.status_synthesis: "Rendering the song..."
settings.render.message.status_encoding: "Writing the file..."
settings.render.message.status_cancelling: "Stopping the render..."
-settings.render.message.status_cancelled: "Render cancelled."
+settings.render.message.status_canceled: "Render canceled."
settings.render.message.status_completed: "Render complete."
settings.render.message.status_failed: "Render failed."
settings.render.message.rendered: "The song was rendered successfully."
settings.render.message.render_failed: "Failed to render the song."
+settings.export.title.window_title: "Exporting"
+settings.export.label.stage_walking: "Playing the song out"
+settings.export.label.stage_compressing: "Compressing the song"
+settings.export.label.stage_writing: "Writing the file"
+settings.export.template.size: "{completed} of {total} bytes"
+settings.export.message.status_cancelling: "Stopping the export..."
settings.keybindings.title.window_title: "Keyboard shortcuts"
settings.keybindings.title.application: "Application"
settings.keybindings.title.order: "Order list"
settings.keybindings.title.tracker: "Tracker"
-settings.keybindings.title.samples: "Samples"
+settings.keybindings.title.voices: "Voices"
settings.keybindings.title.reassign_confirmation: "Combination in use"
settings.keybindings.title.reset_confirmation: "Restore the shipped keys"
settings.keybindings.title.discard_confirmation: "Discard keyboard shortcuts"
@@ -754,6 +856,7 @@ settings.keybindings.label.save_project_as: "Save project as"
settings.keybindings.label.project_properties: "Project properties"
settings.keybindings.label.export_project_famitracker: "Export project to FamiTracker"
settings.keybindings.label.export_project_bitphase: "Export project to Bitphase"
+settings.keybindings.label.export_project_nsf: "Export project to NSF"
settings.keybindings.label.render_song: "Render song to an audio file"
settings.keybindings.label.close_project: "Close project"
settings.keybindings.label.exit: "Exit"
@@ -770,7 +873,11 @@ settings.keybindings.label.close_reconstruction: "Close reconstruction"
settings.keybindings.label.export_reconstruction_wav: "Export reconstruction to WAV"
settings.keybindings.label.export_instruments_famitracker: "Export instruments to FamiTracker"
settings.keybindings.label.export_instruments_bitphase_preset: "Export instruments to a Bitphase preset"
+settings.keybindings.label.export_instruments_nsf: "Export instruments to an NSF program"
settings.keybindings.label.add_reconstruction_to_sequencer: "Add reconstruction to the sequencer"
+settings.keybindings.label.new_instrument: "New instrument"
+settings.keybindings.label.add_sample_from_file: "Add sample from file"
+settings.keybindings.label.import_instrument: "Import a FamiTracker instrument"
settings.keybindings.label.open_reconstruction_in_explorer: "Show reconstruction in the file manager"
settings.keybindings.label.locate_original_audio: "Locate the original audio"
settings.keybindings.label.play: "Play or pause"
@@ -865,13 +972,13 @@ settings.keybindings.label.tracker_clear_row: "Clear row"
settings.keybindings.label.tracker_clear_previous_row: "Clear the previous row"
settings.keybindings.label.tracker_cancel_entry: "Cancel entry"
settings.keybindings.label.tracker_play_from_row: "Play from the current row"
-settings.keybindings.label.samples_rename_sample: "Rename sample"
-settings.keybindings.label.samples_remove_sample: "Remove sample"
-settings.keybindings.label.samples_move_sample_up: "Move sample up"
-settings.keybindings.label.samples_move_sample_down: "Move sample down"
-settings.keybindings.label.samples_move_sample_to_top: "Move sample to the top"
-settings.keybindings.label.samples_move_sample_to_bottom: "Move sample to the bottom"
-settings.keybindings.label.samples_cancel_rename: "Cancel renaming"
+settings.keybindings.label.voices_rename_voice: "Rename voice"
+settings.keybindings.label.voices_remove_voice: "Remove voice"
+settings.keybindings.label.voices_move_voice_up: "Move voice up"
+settings.keybindings.label.voices_move_voice_down: "Move voice down"
+settings.keybindings.label.voices_move_voice_to_top: "Move voice to the top"
+settings.keybindings.label.voices_move_voice_to_bottom: "Move voice to the bottom"
+settings.keybindings.label.voices_cancel_rename: "Cancel renaming"
settings.properties.title.window_title: "Project properties"
settings.properties.label.title: "Title"
settings.properties.label.author: "Author"
diff --git a/src/sampletones_config/layout/general/colors.yaml b/src/sampletones_config/layout/general/colors.yaml
index afb8048a2..fd1995646 100644
--- a/src/sampletones_config/layout/general/colors.yaml
+++ b/src/sampletones_config/layout/general/colors.yaml
@@ -19,4 +19,10 @@ features:
volume: .feature_volume
arpeggio: .feature_arpeggio
pitch: .feature_pitch
+ hi_pitch: .feature_hi_pitch
duty_cycle: .feature_duty_cycle
+channels:
+ pulse1: .channel_pulse1
+ pulse2: .channel_pulse2
+ triangle: .channel_triangle
+ noise: .channel_noise
diff --git a/src/sampletones_config/layout/general/stems.yaml b/src/sampletones_config/layout/general/stems.yaml
new file mode 100644
index 000000000..bdd85a517
--- /dev/null
+++ b/src/sampletones_config/layout/general/stems.yaml
@@ -0,0 +1,6 @@
+master_column_width: 26
+channel_column_width: 90
+remove_button_width: 30
+level_strip_height: 6
+well_padding: 8
+well_margin: 4
diff --git a/src/sampletones_config/layout/glyphs.yaml b/src/sampletones_config/layout/glyphs.yaml
index a564c1537..d1f26de16 100644
--- a/src/sampletones_config/layout/glyphs.yaml
+++ b/src/sampletones_config/layout/glyphs.yaml
@@ -6,6 +6,10 @@ common:
chevron_left: "◂"
chevron_right: "▸"
+voices:
+ sample: "≈"
+ instrument: "∿"
+
headers:
waveform: "∿"
spectrum: "⌇"
@@ -19,7 +23,7 @@ headers:
parameters: "▦"
source: "♪"
instruments: "♬"
- samples: "♫"
+ voices: "♫"
tracker: "▦"
order: "≣"
history: "↺"
diff --git a/src/sampletones_config/layout/graphs/spectrum.yaml b/src/sampletones_config/layout/graphs/spectrum.yaml
index 38a4ac1a6..e4731b30b 100644
--- a/src/sampletones_config/layout/graphs/spectrum.yaml
+++ b/src/sampletones_config/layout/graphs/spectrum.yaml
@@ -1,3 +1,2 @@
max_display_bins: 512
color_dim: .spectrum_dim
-color_bright: .contrast
diff --git a/src/sampletones_config/layout/settings/export.yaml b/src/sampletones_config/layout/settings/export.yaml
new file mode 100644
index 000000000..6843f19d3
--- /dev/null
+++ b/src/sampletones_config/layout/settings/export.yaml
@@ -0,0 +1,3 @@
+window:
+ width: 460
+ height: 120
diff --git a/src/sampletones_config/layout/tabs/main/converter.yaml b/src/sampletones_config/layout/tabs/main/converter.yaml
index 955e82339..3f558a268 100644
--- a/src/sampletones_config/layout/tabs/main/converter.yaml
+++ b/src/sampletones_config/layout/tabs/main/converter.yaml
@@ -1,3 +1,6 @@
width: -1
-height: 180
button_height: 45
+stem_selection:
+ width: 420
+ height: 360
+stem_selection_footer: 44
diff --git a/src/sampletones_config/layout/tabs/sequencer/colors.yaml b/src/sampletones_config/layout/tabs/sequencer/colors.yaml
index 88b613131..c2fd30026 100644
--- a/src/sampletones_config/layout/tabs/sequencer/colors.yaml
+++ b/src/sampletones_config/layout/tabs/sequencer/colors.yaml
@@ -29,15 +29,11 @@ history:
value: .history_value
separator: .history_separator
text:
- instrument: .tracker_reference
+ voice: .text_disabled
transpose: .tracker_transpose
volume: .tracker_volume
- sample: .tracker_reference
+ sample: .voice_sample
+ instrument: .voice_instrument
frame: .tracker_frame
row: .tracker_row
order: .tracker_order
-channels:
- pulse1: .channel_pulse1
- pulse2: .channel_pulse2
- triangle: .channel_triangle
- noise: .channel_noise
diff --git a/src/sampletones_config/layout/tabs/sequencer/table_cells.yaml b/src/sampletones_config/layout/tabs/sequencer/table_cells.yaml
index 2210edf9a..4a766c386 100644
--- a/src/sampletones_config/layout/tabs/sequencer/table_cells.yaml
+++ b/src/sampletones_config/layout/tabs/sequencer/table_cells.yaml
@@ -1,8 +1,8 @@
row: 30
sample: 80
divider: 4
-generator: 80
-instrument:
+channel: 80
+voice:
+ kind: 44
id: 40
name: 1
- loop: 40
diff --git a/src/sampletones_config/layout/tabs/sequencer/tracker.yaml b/src/sampletones_config/layout/tabs/sequencer/tracker.yaml
index ffc48a358..10a90f729 100644
--- a/src/sampletones_config/layout/tabs/sequencer/tracker.yaml
+++ b/src/sampletones_config/layout/tabs/sequencer/tracker.yaml
@@ -2,9 +2,10 @@ rows: 64
page_size: 16
row_height: 29
header_height: 30
+octave_width: 90
channel_column_tint: 0.09
muted_text_fraction: 0.45
subcolumn_widths:
- instrument: 26
+ voice: 26
transpose: 30
volume: 18
diff --git a/src/sampletones_config/palettes/dark.yaml b/src/sampletones_config/palettes/dark.yaml
index 1bf4156f0..cf951bb7e 100644
--- a/src/sampletones_config/palettes/dark.yaml
+++ b/src/sampletones_config/palettes/dark.yaml
@@ -49,6 +49,7 @@ colors:
danger_hover: "#b85850"
danger_active: "#853830"
on_danger: "#f2f2f4"
+ danger_disabled: "#4a3230"
dialog_surface: "#2c2c30"
dialog_title: "#333a44"
@@ -114,7 +115,7 @@ colors:
favorite: "#ffd76e"
favorite_child: "#ddd2ac"
- library_generator: "#cbe6cb"
+ generator: "#cbe6cb"
library_group: "#c8e4e6"
library_instruction: "#cccccf"
library_root: "#dadade"
@@ -151,6 +152,7 @@ colors:
feature_volume: "#7ee787"
feature_arpeggio: "#f0a35e"
feature_pitch: "#6fb8ff"
+ feature_hi_pitch: "#a99cff"
feature_duty_cycle: "#e6c26a"
caret_fill: "#4fa6ff55"
@@ -187,9 +189,11 @@ colors:
history_value: "#d0d0d4ff"
history_separator: "#6c6c74ff"
- tracker_reference: "#e0c860ff"
tracker_transpose: "#c0c0c4ff"
tracker_volume: "#7ee787ff"
tracker_frame: "#4fa6ffff"
tracker_row: "#9a9aa2ff"
tracker_order: "#c4d0e0ff"
+
+ voice_sample: "#e0c860ff"
+ voice_instrument: "#f476d8ff"
diff --git a/src/sampletones_config/palettes/light.yaml b/src/sampletones_config/palettes/light.yaml
index d8a97bca2..04d5d7927 100644
--- a/src/sampletones_config/palettes/light.yaml
+++ b/src/sampletones_config/palettes/light.yaml
@@ -49,6 +49,7 @@ colors:
danger_hover: "#c33a30"
danger_active: "#851f18"
on_danger: "#ffffff"
+ danger_disabled: "#dfd0ce"
dialog_surface: "#f2f4f8"
dialog_title: "#c6cbd5"
@@ -114,7 +115,7 @@ colors:
favorite: "#8a6000"
favorite_child: "#75663c"
- library_generator: "#194c26"
+ generator: "#194c26"
library_group: "#125a60"
library_instruction: "#3d434d"
library_root: "#20252c"
@@ -151,6 +152,7 @@ colors:
feature_volume: "#16702e"
feature_arpeggio: "#a8410a"
feature_pitch: "#0a5aa8"
+ feature_hi_pitch: "#4a3fa8"
feature_duty_cycle: "#7a5c00"
caret_fill: "#0b4c8c58"
@@ -187,9 +189,11 @@ colors:
history_value: "#1d222aff"
history_separator: "#8f97a4ff"
- tracker_reference: "#7a5200ff"
tracker_transpose: "#3d434dff"
tracker_volume: "#16702eff"
tracker_frame: "#0a5aa8ff"
tracker_row: "#5f6773ff"
tracker_order: "#28303dff"
+
+ voice_sample: "#7a5200ff"
+ voice_instrument: "#9a137cff"
diff --git a/src/sampletones_config/palettes/studio.yaml b/src/sampletones_config/palettes/studio.yaml
index 371f5ee4e..3c67dc74b 100644
--- a/src/sampletones_config/palettes/studio.yaml
+++ b/src/sampletones_config/palettes/studio.yaml
@@ -49,6 +49,7 @@ colors:
danger_hover: "#bd6a6a"
danger_active: "#8e4747"
on_danger: "#f1f1f5"
+ danger_disabled: "#4d3d47"
dialog_surface: "#313547"
dialog_title: "#34405e"
@@ -114,7 +115,7 @@ colors:
favorite: "#ffd76e"
favorite_child: "#e7dbb7"
- library_generator: "#d2e8d2"
+ generator: "#d2e8d2"
library_group: "#d2e8e8"
library_instruction: "#d2d2d2"
library_root: "#dcdcdc"
@@ -151,6 +152,7 @@ colors:
feature_volume: "#64ff64"
feature_arpeggio: "#ff9664"
feature_pitch: "#64c8ff"
+ feature_hi_pitch: "#9664ff"
feature_duty_cycle: "#ffc864"
caret_fill: "#8888ff80"
@@ -187,9 +189,11 @@ colors:
history_value: "#d0d0d0ff"
history_separator: "#707070ff"
- tracker_reference: "#e0c860ff"
tracker_transpose: "#c0c0c0ff"
tracker_volume: "#64dc64ff"
tracker_frame: "#22ccffff"
tracker_row: "#a0a0a0ff"
tracker_order: "#c8d0e0ff"
+
+ voice_sample: "#e0c860ff"
+ voice_instrument: "#ff70dfff"
diff --git a/src/sampletones_config/theme/button/danger.yaml b/src/sampletones_config/theme/button/danger.yaml
index f891d4944..180aee994 100644
--- a/src/sampletones_config/theme/button/danger.yaml
+++ b/src/sampletones_config/theme/button/danger.yaml
@@ -16,3 +16,18 @@ components:
- type: color
key: Text
value: .on_danger
+ - item_type: Button
+ enabled: false
+ entries:
+ - type: color
+ key: Text
+ value: .text_disabled
+ - type: color
+ key: Button
+ value: .danger_disabled
+ - type: color
+ key: ButtonHovered
+ value: .danger_disabled
+ - type: color
+ key: ButtonActive
+ value: .danger_disabled
diff --git a/src/sampletones_config/theme/channels/muted.yaml b/src/sampletones_config/theme/channels/muted.yaml
new file mode 100644
index 000000000..659326523
--- /dev/null
+++ b/src/sampletones_config/theme/channels/muted.yaml
@@ -0,0 +1,12 @@
+name: channel_muted
+tag: global.theme.channel_muted
+
+components:
+ - item_type: Checkbox
+ entries:
+ - type: color
+ key: CheckMark
+ value: .text_muted
+ - type: color
+ key: Text
+ value: .text_muted
diff --git a/src/sampletones_config/theme/converter.yaml b/src/sampletones_config/theme/converter.yaml
deleted file mode 100644
index f036c38bf..000000000
--- a/src/sampletones_config/theme/converter.yaml
+++ /dev/null
@@ -1,23 +0,0 @@
-name: converter
-tag: global.theme.converter
-
-components:
- - item_type: All
- entries:
- - type: color
- key: Text
- value: .contrast
- - type: color
- key: TextDisabled
- value: .text_muted
- - type: color
- key: WindowBg
- value: .surface
- - type: color
- key: ChildBg
- value: .surface
- - item_type: Button
- entries:
- - type: color
- key: Button
- value: .button
diff --git a/src/sampletones_config/theme/nodes/library/generator.yaml b/src/sampletones_config/theme/nodes/library/generator.yaml
index 511dcd470..fd438d318 100644
--- a/src/sampletones_config/theme/nodes/library/generator.yaml
+++ b/src/sampletones_config/theme/nodes/library/generator.yaml
@@ -6,4 +6,4 @@ components:
entries:
- type: color
key: Text
- value: .library_generator
+ value: .generator
diff --git a/src/sampletones_config/theme/stems/drop_strip.yaml b/src/sampletones_config/theme/stems/drop_strip.yaml
new file mode 100644
index 000000000..9f8cf9934
--- /dev/null
+++ b/src/sampletones_config/theme/stems/drop_strip.yaml
@@ -0,0 +1,15 @@
+name: stems_drop_strip
+tag: global.theme.stems_drop_strip
+
+components:
+ - item_type: Button
+ entries:
+ - type: color
+ key: Button
+ value: .transparent
+ - type: color
+ key: ButtonHovered
+ value: .transparent
+ - type: color
+ key: ButtonActive
+ value: .transparent
diff --git a/src/sampletones_config/theme/stems/row.yaml b/src/sampletones_config/theme/stems/row.yaml
new file mode 100644
index 000000000..a4c2c0188
--- /dev/null
+++ b/src/sampletones_config/theme/stems/row.yaml
@@ -0,0 +1,9 @@
+name: stems_row
+tag: global.theme.stems_row
+
+components:
+ - item_type: Selectable
+ entries:
+ - type: color
+ key: Text
+ value: .text
diff --git a/src/sampletones_config/theme/stems/row_inert.yaml b/src/sampletones_config/theme/stems/row_inert.yaml
new file mode 100644
index 000000000..2a85e847d
--- /dev/null
+++ b/src/sampletones_config/theme/stems/row_inert.yaml
@@ -0,0 +1,9 @@
+name: stems_row_inert
+tag: global.theme.stems_row_inert
+
+components:
+ - item_type: Selectable
+ entries:
+ - type: color
+ key: Text
+ value: .text_muted
diff --git a/src/sampletones_config/theme/tables/instruments_row.yaml b/src/sampletones_config/theme/tables/voices_row.yaml
similarity index 81%
rename from src/sampletones_config/theme/tables/instruments_row.yaml
rename to src/sampletones_config/theme/tables/voices_row.yaml
index 276e88836..f36c1536f 100644
--- a/src/sampletones_config/theme/tables/instruments_row.yaml
+++ b/src/sampletones_config/theme/tables/voices_row.yaml
@@ -1,5 +1,5 @@
-name: table_instruments_row
-tag: sequencer.instruments.theme.row
+name: table_voices_row
+tag: sequencer.voices.theme.row
components:
- item_type: Table
diff --git a/src/sampletones_core/audio/__init__.py b/src/sampletones_core/audio/__init__.py
index 436ef3c6f..39045bf5c 100644
--- a/src/sampletones_core/audio/__init__.py
+++ b/src/sampletones_core/audio/__init__.py
@@ -1,6 +1,7 @@
from .device import AudioDevice, CurrentDevice
-from .io import load_audio, read_wave, write_wave
+from .io import load_audio, load_stems, read_wave, write_wave
from .manager import CHANNELS, FORMAT, AudioDeviceManager
+from .mixing import align, common_length, mix
from .processing import (
active_frame_level,
amplitude_to_decibels,
@@ -27,12 +28,16 @@
"AudioDeviceManager",
"CurrentDevice",
"active_frame_level",
+ "align",
"amplitude_to_decibels",
"clip_audio",
"clip_audio_inplace",
+ "common_length",
"interpolate",
"load_audio",
+ "load_stems",
"minmax_decimate",
+ "mix",
"normalize",
"quantize",
"read_wave",
diff --git a/src/sampletones_core/audio/io.py b/src/sampletones_core/audio/io.py
index 6d6eedbba..187923a6b 100644
--- a/src/sampletones_core/audio/io.py
+++ b/src/sampletones_core/audio/io.py
@@ -1,5 +1,5 @@
from pathlib import Path
-from typing import Optional, Tuple
+from typing import List, Optional, Sequence, Tuple
import numpy as np
from scipy.io import wavfile
@@ -8,6 +8,7 @@
from sampletones_core.constants.algorithm import QUANTIZATION_LEVELS
from sampletones_shared.types.path import Pathlike
+from .mixing import align, common_length, mix
from .processing import clip_audio
from .processing import normalize as normalize_audio
from .processing import quantize as quantize_audio
@@ -118,3 +119,68 @@ def load_audio(
audio = quantize_audio(audio, levels=quantization_levels)
return audio
+
+
+def load_stems(
+ paths: Sequence[Pathlike],
+ *,
+ target_sample_rate: int,
+ normalize: bool,
+ quantize: bool,
+ quantization_levels: int,
+) -> Tuple[np.ndarray, ...]:
+ """
+ Load a set of recordings onto one shared scale and one shared length.
+
+ The recordings of one piece stand in a balance the piece was mixed at, so the whole
+ set is scaled by a single factor drawn from the peak of their sum. Each recording
+ then keeps the level it holds in the mix, which is what lets one of them be heard on
+ its own at the level it sounds there. Quantization follows the scaling, the order it
+ is defined against.
+
+ Scaling one recording by the peak of its own sum is normalizing it, so a set of one
+ reaches exactly what :func:`load_audio` answers for that path.
+
+ Args:
+ paths: Paths to the recordings, in the order they are returned.
+ target_sample_rate: Sample rate every recording is resampled to.
+ normalize: Whether the set is scaled to the peak of its mix.
+ quantize: Whether each scaled recording is quantized.
+ quantization_levels: Number of amplitude levels used when quantization is enabled.
+
+ Returns:
+ The recordings in the order given, each one the length of the longest.
+
+ Raises:
+ ValueError: If ``target_sample_rate`` is not in the allowed sample rates.
+ FileNotFoundError: If a path names no file.
+ IsADirectoryError: If a path points at a directory.
+ """
+ recordings = [
+ load_audio(
+ path,
+ target_sample_rate=target_sample_rate,
+ normalize=False,
+ quantize=False,
+ )
+ for path in paths
+ ]
+ aligned = align(recordings, common_length(recordings))
+ scaled = _scaled_to_mix(aligned) if normalize else aligned
+ if quantize:
+ return tuple(quantize_audio(recording, levels=quantization_levels) for recording in scaled)
+
+ return tuple(scaled)
+
+
+def _scaled_to_mix(recordings: List[np.ndarray]) -> List[np.ndarray]:
+ """The recordings divided by the peak their mix reaches, silence left as it is."""
+ finite = [np.nan_to_num(recording, nan=0.0, posinf=0.0, neginf=0.0) for recording in recordings]
+ if not finite:
+ return finite
+
+ peak = float(np.max(np.abs(mix(finite))))
+ if peak == 0.0:
+ return finite
+
+ return [(recording / peak).astype(np.float32) for recording in finite]
diff --git a/src/sampletones_core/audio/manager.py b/src/sampletones_core/audio/manager.py
index 66530df58..37427e8b9 100644
--- a/src/sampletones_core/audio/manager.py
+++ b/src/sampletones_core/audio/manager.py
@@ -521,7 +521,7 @@ def play(
update: bool = True,
priority: int = 0,
owner: Optional[Any] = None,
- ) -> None:
+ ) -> bool:
"""
Play audio data.
@@ -539,6 +539,10 @@ def play(
priority: Output-request priority; higher wins. Callers assign the meaning.
owner: Identity of the caller owning this playback, matched by :meth:`replace_audio`
to swap the live buffer only while its own audio is the one playing.
+
+ Returns:
+ bool: Whether this request took the output, which is what a caller following its own
+ playback — drawing a cursor along it — waits for before it starts following.
"""
external_priority = self.call(self.external_output_priority)
with self._lock:
@@ -548,7 +552,7 @@ def play(
(priority for priority in (internal_priority, external_priority) if priority is not None), default=None
)
if held is not None and priority < held:
- return
+ return False
self.stop()
if external_priority is not None:
@@ -572,6 +576,7 @@ def play(
)
self._playback_thread.start()
+ return True
def replace_audio(
self,
diff --git a/src/sampletones_core/audio/mixing.py b/src/sampletones_core/audio/mixing.py
new file mode 100644
index 000000000..fa0c127c9
--- /dev/null
+++ b/src/sampletones_core/audio/mixing.py
@@ -0,0 +1,88 @@
+from typing import Iterable, List, Sequence
+
+import numpy as np
+
+from sampletones_shared.utils.arrays import pad
+
+from .processing import silence
+from .validation import validate_audio_array
+
+
+def common_length(tracks: Iterable[np.ndarray]) -> int:
+ """The length every track reaches when they are laid over one another.
+
+ Args:
+ tracks: The waveforms to measure.
+
+ Returns:
+ int: The length of the longest track, and zero where there are none.
+
+ Examples:
+ >>> common_length([np.zeros(3), np.zeros(7)])
+ 7
+ >>> common_length([])
+ 0
+ """
+ return max((len(track) for track in tracks), default=0)
+
+
+def align(tracks: Sequence[np.ndarray], length: int) -> List[np.ndarray]:
+ """Every track brought to one length, silence filling what a shorter one leaves.
+
+ A shorter track keeps its samples and runs on in silence; a longer one ends at `length`.
+ Waveforms sharing a length stack into a single array, which is what lets a set of sources
+ be summed at once and stored as one another's equals.
+
+ Args:
+ tracks: The waveforms to align, each one-dimensional.
+ length: The length each track reaches.
+
+ Returns:
+ List[np.ndarray]: The tracks in the order given, each one `length` samples long.
+
+ Raises:
+ TypeError: If a track is not a numpy array.
+ ValueError: If a track is not one-dimensional, or if `length` is negative.
+
+ Examples:
+ >>> align([np.array([1.0, 2.0]), np.array([3.0])], 3)
+ [array([1., 2., 0.]), array([3., 0., 0.])]
+ """
+ if length < 0:
+ raise ValueError(f"Length must be at least 0, got {length}")
+
+ for track in tracks:
+ validate_audio_array(track)
+
+ return [pad(track, 0, length) for track in tracks]
+
+
+def mix(tracks: Sequence[np.ndarray]) -> np.ndarray:
+ """The tracks summed into one waveform, as long as the longest of them.
+
+ Every track carries the level it reaches the mix at — a generator bakes its channel's mixer
+ weight into what it renders, and a recorded stem carries the level it was captured at — so a
+ plain sum is what combines them once they share a length.
+
+ Args:
+ tracks: The waveforms to mix, each one-dimensional.
+
+ Returns:
+ np.ndarray: The mixed waveform, empty where no track sounds.
+
+ Raises:
+ TypeError: If a track is not a numpy array.
+ ValueError: If a track is not one-dimensional.
+
+ Examples:
+ >>> mix([np.array([1.0, 1.0]), np.array([0.5])])
+ array([1.5, 1. ], dtype=float32)
+ >>> mix([])
+ array([], dtype=float32)
+ """
+ aligned = align(tracks, common_length(tracks))
+ if not aligned:
+ return silence(0)
+
+ mixed: np.ndarray = np.sum(np.array(aligned), axis=0).astype(np.float32)
+ return mixed
diff --git a/src/sampletones_core/calibration/referee/zimtohrli.py b/src/sampletones_core/calibration/referee/zimtohrli.py
index 0a5157fb9..37402f2b9 100644
--- a/src/sampletones_core/calibration/referee/zimtohrli.py
+++ b/src/sampletones_core/calibration/referee/zimtohrli.py
@@ -1,4 +1,3 @@
-import shutil
import subprocess
import tempfile
from pathlib import Path
@@ -7,6 +6,7 @@
import numpy as np
from sampletones_core.audio.io import write_wave
+from sampletones_shared.utils.system.programs import locate_program
ZIMTOHRLI_BINARY: Final[str] = "zimtohrli"
@@ -58,5 +58,4 @@ def _is_float(token: str) -> bool:
def find_zimtohrli() -> Optional[Path]:
"""Path of the `zimtohrli` binary on the system, when installed."""
- binary = shutil.which(ZIMTOHRLI_BINARY)
- return Path(binary) if binary else None
+ return locate_program(ZIMTOHRLI_BINARY)
diff --git a/src/sampletones_core/compatibility/fields.py b/src/sampletones_core/compatibility/fields.py
new file mode 100644
index 000000000..c5223a00e
--- /dev/null
+++ b/src/sampletones_core/compatibility/fields.py
@@ -0,0 +1,42 @@
+from typing import Final
+
+CONFIG: Final = "config"
+METADATA: Final = "metadata"
+GENERATION: Final = "generation"
+GENERATORS: Final = "generators"
+
+AUDIO_FILEPATH: Final = "audio_filepath"
+INSTRUCTIONS_DATA: Final = "instructions_data"
+APPROXIMATIONS_DATA: Final = "approximations_data"
+RECONSTRUCTION_DATA_VERSION: Final = "reconstruction_data_version"
+
+STEMS_DATA: Final = "stems_data"
+ENTRIES: Final = "entries"
+ID: Final = "id"
+HIERARCHY: Final = "hierarchy"
+LEVELS: Final = "levels"
+MODE: Final = "mode"
+CHANNEL_CAP: Final = "channel_cap"
+ASSIGNMENTS: Final = "assignments"
+STEM_IDS: Final = "stem_ids"
+INSTRUCTIONS: Final = "instructions"
+
+CHANNELS: Final = "channels"
+GENERATOR: Final = "generator"
+NAME: Final = "name"
+
+SONG: Final = "song"
+PATTERNS: Final = "patterns"
+ROWS: Final = "rows"
+COMMAND: Final = "command"
+
+CHANNEL_NAME: Final = "channel_name"
+GENERATOR_NAME: Final = "generator_name"
+
+SAMPLES: Final = "samples"
+VOICES: Final = "voices"
+KIND: Final = "kind"
+KIND_SAMPLE: Final = "sample"
+LOOP_POINT: Final = "loop_point"
+SAMPLE_ID: Final = "sample_id"
+VOICE_ID: Final = "voice_id"
diff --git a/src/sampletones_core/compatibility/library/__init__.py b/src/sampletones_core/compatibility/library/__init__.py
new file mode 100644
index 000000000..81c9116f6
--- /dev/null
+++ b/src/sampletones_core/compatibility/library/__init__.py
@@ -0,0 +1,5 @@
+from typing import Final, Tuple
+
+from sampletones_core.compatibility.update import VersionUpdate
+
+UPDATES: Final[Tuple[VersionUpdate, ...]] = ()
diff --git a/src/sampletones_core/compatibility/project/__init__.py b/src/sampletones_core/compatibility/project/__init__.py
new file mode 100644
index 000000000..ca3fac1d0
--- /dev/null
+++ b/src/sampletones_core/compatibility/project/__init__.py
@@ -0,0 +1,7 @@
+from typing import Final, Tuple
+
+from sampletones_core.compatibility.update import VersionUpdate
+
+from .v1_1 import V1_1
+
+UPDATES: Final[Tuple[VersionUpdate, ...]] = (V1_1,)
diff --git a/src/sampletones_core/compatibility/project/v1_1.py b/src/sampletones_core/compatibility/project/v1_1.py
new file mode 100644
index 000000000..a843060dc
--- /dev/null
+++ b/src/sampletones_core/compatibility/project/v1_1.py
@@ -0,0 +1,107 @@
+from typing import Final, List
+
+from sampletones_core.compatibility.fields import (
+ CHANNELS,
+ COMMAND,
+ GENERATOR,
+ KIND,
+ KIND_SAMPLE,
+ LOOP_POINT,
+ NAME,
+ PATTERNS,
+ ROWS,
+ SAMPLE_ID,
+ SAMPLES,
+ SONG,
+ VOICE_ID,
+ VOICES,
+)
+from sampletones_core.compatibility.kind import ObjectKind
+from sampletones_core.compatibility.update import VersionUpdate
+from sampletones_shared.deployment.version import Version
+from sampletones_shared.types.data import SerializedData
+
+
+def update(data: SerializedData) -> SerializedData:
+ """Gathers a project's samples into its voices, and names each channel once.
+
+ Project format 1.0 held the pool under ``samples``, stored a channel pool's channel under
+ ``generator``, wrote a row's note command as a sample id beside the channel slice it named, and
+ let a sample state a tick its frames repeated from. Project format 1.1 holds the pool under
+ ``voices``, each record stating the ``kind`` of voice it carries; a channel pool names its
+ channel under ``name``; a note command names the voice alone, since the channel a voice sounds
+ on is the one whose pattern holds the row; and a sample plays the frames its conversion found,
+ which leaves the repeat to the envelopes an instrument writes.
+ """
+ updated = dict(data)
+
+ samples = data.get(SAMPLES)
+ if isinstance(samples, list):
+ updated.pop(SAMPLES, None)
+ updated[VOICES] = [_updated_sample(sample) if isinstance(sample, dict) else sample for sample in samples]
+
+ song = data.get(SONG)
+ if isinstance(song, dict):
+ updated[SONG] = _updated_song(song)
+
+ return updated
+
+
+def _updated_sample(sample: SerializedData) -> SerializedData:
+ """A 1.0 sample as a voice record, leaving behind the tick its frames repeated from."""
+ record = {KIND: KIND_SAMPLE, **sample}
+ record.pop(LOOP_POINT, None)
+ return record
+
+
+def _updated_song(song: SerializedData) -> SerializedData:
+ channels = song.get(CHANNELS)
+ if not isinstance(channels, dict):
+ return song
+
+ return {
+ **song,
+ CHANNELS: {
+ name: _updated_pool(channel) if isinstance(channel, dict) else channel for name, channel in channels.items()
+ },
+ }
+
+
+def _updated_pool(channel: SerializedData) -> SerializedData:
+ updated = dict(channel)
+ if GENERATOR in updated:
+ updated[NAME] = updated.pop(GENERATOR)
+
+ patterns = channel.get(PATTERNS)
+ if isinstance(patterns, dict):
+ updated[PATTERNS] = {
+ index: _updated_pattern(pattern) if isinstance(pattern, dict) else pattern
+ for index, pattern in patterns.items()
+ }
+
+ return updated
+
+
+def _updated_pattern(pattern: SerializedData) -> SerializedData:
+ rows = pattern.get(ROWS)
+ if not isinstance(rows, list):
+ return pattern
+
+ updated_rows: List[SerializedData] = [_updated_row(row) if isinstance(row, dict) else row for row in rows]
+ return {**pattern, ROWS: updated_rows}
+
+
+def _updated_row(row: SerializedData) -> SerializedData:
+ command = row.get(COMMAND)
+ if not isinstance(command, dict) or SAMPLE_ID not in command:
+ return row
+
+ return {**row, COMMAND: {VOICE_ID: command[SAMPLE_ID]}}
+
+
+V1_1: Final[VersionUpdate] = VersionUpdate(
+ kind=ObjectKind.PROJECT,
+ base=Version.model_validate("1.0"),
+ target=Version.model_validate("1.1"),
+ apply=update,
+)
diff --git a/src/sampletones_core/compatibility/reconstruction/__init__.py b/src/sampletones_core/compatibility/reconstruction/__init__.py
new file mode 100644
index 000000000..ce557a3a9
--- /dev/null
+++ b/src/sampletones_core/compatibility/reconstruction/__init__.py
@@ -0,0 +1,7 @@
+from typing import Final, Tuple
+
+from sampletones_core.compatibility.update import VersionUpdate
+
+from .v2_2 import V2_2
+
+UPDATES: Final[Tuple[VersionUpdate, ...]] = (V2_2,)
diff --git a/src/sampletones_core/compatibility/reconstruction/v2_2.py b/src/sampletones_core/compatibility/reconstruction/v2_2.py
new file mode 100644
index 000000000..38eeb0cc8
--- /dev/null
+++ b/src/sampletones_core/compatibility/reconstruction/v2_2.py
@@ -0,0 +1,155 @@
+from typing import Any, Final
+
+from sampletones_core.compatibility.fields import (
+ APPROXIMATIONS_DATA,
+ ASSIGNMENTS,
+ AUDIO_FILEPATH,
+ CHANNEL_CAP,
+ CHANNEL_NAME,
+ CHANNELS,
+ CONFIG,
+ ENTRIES,
+ GENERATION,
+ GENERATOR_NAME,
+ GENERATORS,
+ HIERARCHY,
+ ID,
+ INSTRUCTIONS,
+ INSTRUCTIONS_DATA,
+ LEVELS,
+ METADATA,
+ MODE,
+ RECONSTRUCTION_DATA_VERSION,
+ STEM_IDS,
+ STEMS_DATA,
+)
+from sampletones_core.compatibility.kind import ObjectKind
+from sampletones_core.compatibility.update import VersionUpdate
+from sampletones_core.compatibility.utils import renamed
+from sampletones_core.constants.algorithm import (
+ DEFAULT_STEMS_CHANNEL_CAP,
+ DEFAULT_STEMS_HIERARCHY_MODE,
+)
+from sampletones_shared.deployment.version import Version
+from sampletones_shared.types.data import SerializedData
+
+SOURCE_DATA_VERSION: Final[str] = "2.1"
+TARGET_DATA_VERSION: Final[str] = "2.2"
+
+
+def _normalized_audio_filepath(data: SerializedData) -> Any:
+ raw = data.get(AUDIO_FILEPATH)
+ if raw is None:
+ return []
+
+ if isinstance(raw, list):
+ return raw
+
+ return [raw]
+
+
+def _default_stems_data(data: SerializedData) -> SerializedData:
+ """The single-entry stems record a conversion predating stems carries.
+
+ One stem covers every enabled channel and owns every frame of each channel that plays,
+ which is the classic run's shape, so the synthesized record states what the
+ reconstruction is.
+ """
+ config = data.get(CONFIG)
+ channels = config.get(GENERATION, {}).get(CHANNELS, []) if isinstance(config, dict) else []
+ instructions_data = data.get(INSTRUCTIONS_DATA)
+ stream_items = instructions_data if isinstance(instructions_data, list) else []
+ assignments = [
+ {
+ CHANNEL_NAME: item.get(CHANNEL_NAME),
+ STEM_IDS: [0] * len(item.get(INSTRUCTIONS, [])),
+ }
+ for item in stream_items
+ if isinstance(item, dict) and item.get(INSTRUCTIONS)
+ ]
+ return {
+ CONFIG: {
+ ENTRIES: [{ID: 0, CHANNELS: channels}],
+ HIERARCHY: {LEVELS: [[0]], MODE: str(DEFAULT_STEMS_HIERARCHY_MODE)},
+ CHANNEL_CAP: DEFAULT_STEMS_CHANNEL_CAP,
+ },
+ ASSIGNMENTS: assignments,
+ }
+
+
+def _renamed_stream_keys(data: SerializedData) -> SerializedData:
+ """The stream and approximation sections keyed by channel name."""
+ updated = dict(data)
+ for section in (APPROXIMATIONS_DATA, INSTRUCTIONS_DATA):
+ entries = data.get(section)
+ if isinstance(entries, list):
+ updated[section] = [renamed(item, GENERATOR_NAME, CHANNEL_NAME) for item in entries]
+
+ return updated
+
+
+def _stamped_embedded_config(data: SerializedData) -> SerializedData:
+ """The embedded config named by channel and stamped with the target version."""
+ updated = dict(data)
+ config = data.get(CONFIG)
+ if not isinstance(config, dict):
+ return updated
+
+ updated_config = dict(config)
+ metadata = config.get(METADATA)
+ if isinstance(metadata, dict) and isinstance(
+ metadata.get(RECONSTRUCTION_DATA_VERSION),
+ str,
+ ):
+ updated_config[METADATA] = {
+ **metadata,
+ RECONSTRUCTION_DATA_VERSION: TARGET_DATA_VERSION,
+ }
+
+ generation = config.get(GENERATION)
+ if isinstance(generation, dict):
+ updated_config[GENERATION] = renamed(generation, GENERATORS, CHANNELS)
+
+ updated[CONFIG] = updated_config
+ return updated
+
+
+def _normalized_source_paths(data: SerializedData) -> SerializedData:
+ """The recorded source audio as one path per stem."""
+ updated = dict(data)
+ updated[AUDIO_FILEPATH] = _normalized_audio_filepath(data)
+ return updated
+
+
+def _with_default_stems_record(data: SerializedData) -> SerializedData:
+ """The single-entry stems record, present on every reconstruction."""
+ updated = dict(data)
+ if STEMS_DATA not in updated:
+ updated[STEMS_DATA] = _default_stems_data(updated)
+
+ return updated
+
+
+def update(data: SerializedData) -> SerializedData:
+ """Names each stored stream and approximation by its channel.
+
+ Data version 2.1 stored a channel's stream and approximation under the key
+ ``generator_name`` and the channel selection under
+ ``config.generation.generators``. Data version 2.2 names them ``channel_name``
+ and ``config.generation.channels``, stamps the embedded config's metadata with the
+ new data version, records the source audio as one path per stem, and carries the
+ single-entry stems record every reconstruction states.
+ """
+ updated = dict(data)
+ updated = _renamed_stream_keys(updated)
+ updated = _stamped_embedded_config(updated)
+ updated = _normalized_source_paths(updated)
+ return _with_default_stems_record(updated)
+
+
+V2_2: Final[VersionUpdate] = VersionUpdate(
+ kind=ObjectKind.RECONSTRUCTION,
+ base=Version.model_validate(SOURCE_DATA_VERSION),
+ target=Version.model_validate(TARGET_DATA_VERSION),
+ apply=update,
+)
diff --git a/src/sampletones_core/compatibility/update.py b/src/sampletones_core/compatibility/update.py
index fdc41e654..8a63adeca 100644
--- a/src/sampletones_core/compatibility/update.py
+++ b/src/sampletones_core/compatibility/update.py
@@ -1,10 +1,16 @@
-from typing import NamedTuple
+from typing import Callable, NamedTuple
from sampletones_core.compatibility.kind import ObjectKind
from sampletones_shared.deployment.version import Version
+from sampletones_shared.types.data import SerializedData
+
+UpdateFunction = Callable[[SerializedData], SerializedData]
class VersionUpdate(NamedTuple):
+ """One step of a format upgrade: the version it reads, the one it writes, and the transform."""
+
kind: ObjectKind
base: Version
target: Version
+ apply: UpdateFunction
diff --git a/src/sampletones_core/compatibility/upgrade.py b/src/sampletones_core/compatibility/upgrade.py
new file mode 100644
index 000000000..e98940df6
--- /dev/null
+++ b/src/sampletones_core/compatibility/upgrade.py
@@ -0,0 +1,249 @@
+import json
+from typing import Any, Dict, Final, List, NamedTuple, Optional, Tuple
+
+import msgpack
+
+from sampletones_core.compatibility.kind import ObjectKind
+from sampletones_core.compatibility.library import UPDATES as LIBRARY_UPDATES
+from sampletones_core.compatibility.project import UPDATES as PROJECT_UPDATES
+from sampletones_core.compatibility.reconstruction import UPDATES as RECONSTRUCTION_UPDATES
+from sampletones_core.compatibility.update import VersionUpdate
+from sampletones_shared.application import (
+ SAMPLETONES_LIBRARY_DATA_VERSION,
+ SAMPLETONES_PROJECT_DATA_VERSION,
+ SAMPLETONES_RECONSTRUCTION_DATA_VERSION,
+)
+from sampletones_shared.deployment.version import Version, compare_versions
+from sampletones_shared.types.data import SerializedData
+
+CURRENT_VERSIONS: Final[Dict[ObjectKind, str]] = {
+ ObjectKind.LIBRARY: SAMPLETONES_LIBRARY_DATA_VERSION,
+ ObjectKind.RECONSTRUCTION: SAMPLETONES_RECONSTRUCTION_DATA_VERSION,
+ ObjectKind.PROJECT: SAMPLETONES_PROJECT_DATA_VERSION,
+}
+
+UPDATES: Final[Dict[ObjectKind, Tuple[VersionUpdate, ...]]] = {
+ ObjectKind.LIBRARY: LIBRARY_UPDATES,
+ ObjectKind.RECONSTRUCTION: RECONSTRUCTION_UPDATES,
+ ObjectKind.PROJECT: PROJECT_UPDATES,
+}
+
+
+class _VersionPath(NamedTuple):
+ """Where a format stores its version: the section enclosing the field, and the field itself."""
+
+ section: Optional[str]
+ field: str
+
+
+def upgrade(
+ kind: ObjectKind,
+ version: str,
+ data: SerializedData,
+ updates: Tuple[VersionUpdate, ...],
+ current_version: str,
+) -> SerializedData:
+ """Brings one format's serialized data to the version this build reads and writes.
+
+ A format stores the version its file was written at; the load path hands that
+ version and the payload here before deserialization, so a file written by an
+ older build loads correctly even after the format's stored shape changed.
+
+ The engine walks the registered steps of ``kind``, applying the step whose
+ base version matches the position reached, until ``current_version`` is met.
+ The walk follows these rules:
+
+ - a step runs only inside a complete path from ``version`` to
+ ``current_version``; when a position has no continuing step, the data
+ comes back unchanged and the format's load contract refuses the file, the
+ same outcome as for any version this build does not support;
+ - each step transforms the payload the previous step produced;
+ - a walk that ran stamps the format's version field with ``current_version``,
+ so the data states the version its shape now matches and a later save
+ writes that version;
+ - data already at ``current_version`` returns as the same object, which is
+ how the byte-level wrappers keep their path of skipping re-encoding.
+
+ Args:
+ kind: The format whose update chain applies.
+ version: The version the data states.
+ data: The serialized data to upgrade.
+ updates: The registered steps for the format.
+ current_version: The version this build reads and writes.
+
+ Returns:
+ SerializedData: The data upgraded and stamped with ``current_version``,
+ or the input unchanged when the chain does not reach it.
+ """
+ chain = _resolve_chain(
+ version,
+ updates,
+ current_version,
+ )
+
+ if not chain:
+ return data
+
+ upgraded = data
+ for update in chain:
+ upgraded = update.apply(upgraded)
+
+ return _stamp(kind, upgraded, current_version)
+
+
+def upgrade_binary(kind: ObjectKind, binary: bytes) -> bytes:
+ """Upgrades a msgpack payload at the load boundary of a binary format.
+
+ The reconstruction and library formats store their data as msgpack mappings
+ whose ``metadata`` section carries ``_data_version``. This wrapper
+ unpacks the payload, reads that version, runs :func:`upgrade`, and re-encodes
+ the upgraded mapping. It returns the input bytes unchanged whenever the
+ payload stays as it is: when it does not unpack to a mapping, when it lacks
+ the version field, or when the chain does not apply.
+ """
+ try:
+ data = msgpack.unpackb(binary, raw=False)
+ except ValueError:
+ return binary
+
+ upgraded = _upgrade_payload(kind, data)
+ if upgraded is None:
+ return binary
+
+ return bytes(msgpack.packb(upgraded, use_bin_type=True))
+
+
+def upgrade_json(kind: ObjectKind, raw: bytes) -> bytes:
+ """Upgrades a JSON document at the load boundary of a JSON format.
+
+ The project format stores ``format_version`` at the document root. This
+ wrapper parses the document, reads that version, runs :func:`upgrade`, and
+ re-encodes the upgraded document. It returns the input bytes unchanged
+ whenever the document stays as it is: when it does not parse to a mapping,
+ when it lacks the version field, or when the chain does not apply.
+ """
+ try:
+ data = json.loads(raw)
+ except ValueError:
+ return raw
+
+ upgraded = _upgrade_payload(kind, data)
+ if upgraded is None:
+ return raw
+
+ return json.dumps(upgraded).encode("utf-8")
+
+
+def _upgrade_kind(
+ kind: ObjectKind,
+ version: str,
+ data: SerializedData,
+) -> SerializedData:
+ return upgrade(
+ kind,
+ version,
+ data,
+ UPDATES[kind],
+ CURRENT_VERSIONS[kind],
+ )
+
+
+def _upgrade_payload(
+ kind: ObjectKind,
+ payload: Any,
+) -> Optional[SerializedData]:
+ """Runs the format's chain over a parsed payload.
+
+ Returns the upgraded mapping, or ``None`` when the payload stays as it is:
+ when it is not a mapping, when it lacks the format's version field, or when
+ the chain does not apply.
+ """
+ if not isinstance(payload, dict):
+ return None
+
+ version = _read_version(kind, payload)
+ if version is None:
+ return None
+
+ upgraded = _upgrade_kind(kind, version, payload)
+ return None if upgraded is payload else upgraded
+
+
+def _version_path(kind: ObjectKind) -> _VersionPath:
+ if kind is ObjectKind.PROJECT:
+ return _VersionPath(
+ section=None,
+ field="format_version",
+ )
+
+ return _VersionPath(
+ section="metadata",
+ field=f"{kind.value}_data_version",
+ )
+
+
+def _read_version(kind: ObjectKind, data: SerializedData) -> Optional[str]:
+ path = _version_path(kind)
+ section_name = path.section
+ if section_name is None:
+ value = data.get(path.field)
+ else:
+ section = data.get(section_name)
+ if not isinstance(section, dict):
+ return None
+
+ value = section.get(path.field)
+
+ return value if isinstance(value, str) else None
+
+
+def _stamp(
+ kind: ObjectKind,
+ data: SerializedData,
+ version: str,
+) -> SerializedData:
+ path = _version_path(kind)
+ stamped = dict(data)
+ section_name = path.section
+ if section_name is None:
+ stamped[path.field] = version
+ return stamped
+
+ section = data.get(section_name)
+ if isinstance(section, dict):
+ stamped[section_name] = {**section, path.field: version}
+
+ return stamped
+
+
+def _resolve_chain(
+ version: str,
+ updates: Tuple[VersionUpdate, ...],
+ current_version: str,
+) -> Tuple[VersionUpdate, ...]:
+ by_base: Dict[str, VersionUpdate] = {}
+ for update in updates:
+ base = _canonical(str(update.base))
+ if base in by_base:
+ raise ValueError(f"Duplicate base version {base} among registered updates")
+
+ by_base[base] = update
+
+ chain: List[VersionUpdate] = []
+ position = _canonical(version)
+ for _ in range(len(updates) + 1):
+ if compare_versions(position, current_version) == 0:
+ return tuple(chain)
+
+ matching = by_base.get(position)
+ if matching is None:
+ return ()
+
+ chain.append(matching)
+ position = _canonical(str(matching.target))
+
+ return ()
+
+
+def _canonical(version: str) -> str:
+ return str(Version.model_validate(version))
diff --git a/src/sampletones_core/compatibility/utils.py b/src/sampletones_core/compatibility/utils.py
new file mode 100644
index 000000000..77a912b9e
--- /dev/null
+++ b/src/sampletones_core/compatibility/utils.py
@@ -0,0 +1,9 @@
+from sampletones_shared.types.data import SerializedData
+
+
+def renamed(data: SerializedData, old_key: str, new_key: str) -> SerializedData:
+ entries = dict(data)
+ if old_key in entries:
+ entries[new_key] = entries.pop(old_key)
+
+ return entries
diff --git a/src/sampletones_core/configs/config.py b/src/sampletones_core/configs/config.py
index 2dafc7968..af501b8cf 100644
--- a/src/sampletones_core/configs/config.py
+++ b/src/sampletones_core/configs/config.py
@@ -5,19 +5,19 @@
from pydantic import ConfigDict, Field
-from sampletones_core.constants.enums import GeneratorName
+from sampletones_core.configs.general import GeneralConfig
+from sampletones_core.configs.generation import GenerationConfig
+from sampletones_core.configs.library import InstructionsLibraryConfig
+from sampletones_core.constants.enums import ChannelName
from sampletones_core.data import DataModel
from sampletones_core.data.metadata import Metadata
+from sampletones_shared.music import Tuning
from sampletones_shared.paths.user import CONFIG_PATH
from sampletones_shared.types.path import Pathlike
from sampletones_shared.utils.serialization import load_json, save_json
from sampletones_shared.utils.system.paths import to_path
from sampletones_shared.utils.validation import validate_with_recovery
-from .general import GeneralConfig
-from .generation import GenerationConfig
-from .library import InstructionsLibraryConfig
-
class Config(DataModel):
model_config = ConfigDict(arbitrary_types_allowed=True, extra="forbid", frozen=True)
@@ -111,8 +111,8 @@ def drive(self) -> float:
return self.generation.drive
@property
- def generators(self) -> List[GeneratorName]:
- return self.generation.generators.copy()
+ def channels(self) -> List[ChannelName]:
+ return self.generation.channels.copy()
@property
def normalize(self) -> bool:
@@ -130,6 +130,10 @@ def nes_frequency(self) -> int:
def sample_rate(self) -> int:
return self.library.sample_rate
+ @property
+ def tuning(self) -> Tuning:
+ return self.library.tuning
+
@property
def frame_length(self) -> int:
return self.library.frame_length
diff --git a/src/sampletones_core/configs/display.py b/src/sampletones_core/configs/display.py
index 04a1750db..69e9af48e 100644
--- a/src/sampletones_core/configs/display.py
+++ b/src/sampletones_core/configs/display.py
@@ -1,7 +1,7 @@
from collections import Counter
from typing import Dict, Final, Sequence, Tuple
-from sampletones_core.constants.enums import GeneratorName, SpectrumMethod
+from sampletones_core.constants.enums import ChannelName, SpectrumMethod
from sampletones_shared.constants.symbols import HASH
DISPLAY_SEPARATOR: Final[str] = "·"
@@ -45,9 +45,9 @@ def format_transformation_gamma(transformation_gamma: int) -> str:
return f"{GAMMA_PREFIX}{transformation_gamma}"
-def format_generators(generators: Sequence[GeneratorName]) -> str:
- """Renders the generators a reconstruction was built with, in the order it names them (e.g. ``Pulse 1, Noise``)."""
- return GENERATOR_SEPARATOR.join(generator.capitalized for generator in generators)
+def format_channels(channels: Sequence[ChannelName]) -> str:
+ """Renders the channels a reconstruction was built with, in the order it names them (e.g. ``Pulse 1, Noise``)."""
+ return GENERATOR_SEPARATOR.join(channel.capitalized for channel in channels)
def format_frequencies(sample_rate: int, nes_frequency: int) -> str:
diff --git a/src/sampletones_core/configs/generation.py b/src/sampletones_core/configs/generation.py
index df8e54dbb..1c9a7ba88 100644
--- a/src/sampletones_core/configs/generation.py
+++ b/src/sampletones_core/configs/generation.py
@@ -12,6 +12,10 @@
MAX_DRIVE,
PERCEPTUAL_EXPONENT,
PHASE_ALIGNER,
+ REFINE_PITCH,
+ REFINEMENT_CHANGE_WEIGHT,
+ REFINEMENT_CONFIDENCE,
+ REFINEMENT_WINDOW,
RESET_PHASE,
SELECTOR,
SPECTRAL_DISTANCE,
@@ -24,8 +28,8 @@
TRANSITION_VOLUME_WEIGHT,
)
from sampletones_core.constants.enums import (
- DEFAULT_GENERATORS,
- GeneratorName,
+ DEFAULT_CHANNELS,
+ ChannelName,
PhaseAlignerName,
SelectorName,
SpectralDistance,
@@ -68,6 +72,29 @@ class DecoderConfig(DataModel):
on_off_weight: float = Field(default=TRANSITION_ON_OFF_WEIGHT, ge=0.0)
+class RefinementConfig(DataModel):
+ """How far off the equal-tempered grid a conversion is allowed to place its notes.
+
+ A note reaches the hardware as a divider, and the divider grid is finer than the note grid
+ everywhere below the top of the range. The refinement reads where each frame's fundamental
+ actually stands and bends the note it landed on towards it, so material recorded off the grid
+ comes back in tune with itself.
+
+ Attributes:
+ enabled: Whether a conversion bends the notes it chose.
+ confidence: The share of a frame's energy its harmonics must hold for its reading to count.
+ change_weight: The divider steps of reading error worth avoiding one change of bend.
+ window: The frames on either side whose readings a frame may settle on.
+ """
+
+ model_config = ConfigDict(extra="forbid", frozen=True)
+
+ enabled: bool = Field(default=REFINE_PITCH)
+ confidence: float = Field(default=REFINEMENT_CONFIDENCE, ge=0.0, le=1.0)
+ change_weight: float = Field(default=REFINEMENT_CHANGE_WEIGHT, ge=0.0)
+ window: int = Field(default=REFINEMENT_WINDOW, ge=0)
+
+
class GenerationConfig(DataModel):
model_config = ConfigDict(extra="forbid", frozen=True)
@@ -84,8 +111,15 @@ class GenerationConfig(DataModel):
reset_phase: bool = Field(default=RESET_PHASE)
final_regeneration: bool = Field(default=FINAL_REGENERATION)
- generators: List[GeneratorName] = Field(default_factory=DEFAULT_GENERATORS.copy)
+ channels: List[ChannelName] = Field(
+ default_factory=DEFAULT_CHANNELS.copy,
+ validation_alias=AliasChoices(
+ "channels",
+ "generators",
+ ),
+ )
calculation: CalculationConfig = Field(default_factory=CalculationConfig)
weights: WeightsConfig = Field(default_factory=WeightsConfig)
metric: MetricConfig = Field(default_factory=MetricConfig)
decoder: DecoderConfig = Field(default_factory=DecoderConfig)
+ refinement: RefinementConfig = Field(default_factory=RefinementConfig)
diff --git a/src/sampletones_core/configs/library.py b/src/sampletones_core/configs/library.py
index 0cdfa86f4..de9bc596d 100644
--- a/src/sampletones_core/configs/library.py
+++ b/src/sampletones_core/configs/library.py
@@ -8,19 +8,16 @@
MIN_SAMPLE_RATE,
)
from sampletones_core.constants.enums import SpectrumMethod
-from sampletones_core.constants.general import (
- A4_FREQUENCY,
- A4_PITCH,
- LIMIT_MAX_PITCH,
- MIN_FREQUENCY,
-)
+from sampletones_core.constants.general import MIN_FREQUENCY
from sampletones_core.constants.spectrum import BINS_PER_OCTAVE, CQT_CUTOFF_FREQUENCY
from sampletones_core.data import DataModel
+from sampletones_shared.constants.music import A4_FREQUENCY, A4_PITCH
from sampletones_shared.constants.nes import (
DEFAULT_NES_FREQUENCY,
MAX_NES_FREQUENCY,
MIN_NES_FREQUENCY,
)
+from sampletones_shared.music import ReferenceFrequency, ReferencePitch, Tuning
class InstructionsLibraryConfig(DataModel):
@@ -46,20 +43,24 @@ class InstructionsLibraryConfig(DataModel):
ge=0,
le=MAX_TRANSFORMATION_GAMMA,
)
- a4_frequency: float = Field(
+ a4_frequency: ReferenceFrequency = Field(
default=A4_FREQUENCY,
- gt=20.0,
- lt=20000.0,
)
- a4_pitch: int = Field(
+ a4_pitch: ReferencePitch = Field(
default=A4_PITCH,
- ge=1,
- le=LIMIT_MAX_PITCH,
)
spectrum_method: SpectrumMethod = Field(
default=SpectrumMethod.CQT,
)
+ @property
+ def tuning(self) -> Tuning:
+ """The tuning the reconstruction's pitches are measured from."""
+ return Tuning(
+ a4_frequency=self.a4_frequency,
+ a4_pitch=self.a4_pitch,
+ )
+
@property
def frame_length(self) -> int:
return round(self.sample_rate / self.nes_frequency)
diff --git a/src/sampletones_core/constants/algorithm.py b/src/sampletones_core/constants/algorithm.py
index 0808ec5df..405401fd1 100644
--- a/src/sampletones_core/constants/algorithm.py
+++ b/src/sampletones_core/constants/algorithm.py
@@ -1,7 +1,19 @@
from typing import Final
-from .enums import PhaseAlignerName, SelectorName, SpectralDistance
-from .general import MAX_VOLUME, MIN_VOLUME
+from sampletones_core.constants.enums import (
+ ChannelName,
+ HierarchyMode,
+ PhaseAlignerName,
+ SelectorName,
+ SpectralDistance,
+)
+from sampletones_core.constants.general import (
+ MAX_VOLUME,
+ MIN_VOLUME,
+ MIXER_NOISE,
+ MIXER_PULSE,
+ MIXER_TRIANGLE,
+)
# Matching floors
@@ -47,16 +59,33 @@
SELECTOR: Final[SelectorName] = SelectorName.VITERBI
DECODER_TOP_K: Final[int] = 8
+SINGLE_STATE_LATTICE_WIDTH: Final[int] = 1
TRANSITION_PITCH_WEIGHT: Final[float] = 0.03
TRANSITION_VOLUME_WEIGHT: Final[float] = 0.02
TRANSITION_TIMBRE_WEIGHT: Final[float] = 0.10
TRANSITION_ON_OFF_WEIGHT: Final[float] = 0.20
+# Pitch refinement
+
+REFINE_PITCH: Final[bool] = True
+REFINEMENT_CONFIDENCE: Final[float] = 0.15
+REFINEMENT_CHANGE_WEIGHT: Final[float] = 2.0
+REFINEMENT_WINDOW: Final[int] = 4
+
# Mixer drive
DRIVE: Final[float] = 1.0
MAX_DRIVE: Final[float] = 5.0
+# Stems assignment
+
+ALL_STEMS_CHANNEL_CAP: Final[int] = len(ChannelName)
+DEFAULT_STEMS_CHANNEL_CAP: Final[int] = ALL_STEMS_CHANNEL_CAP
+DEFAULT_STEMS_HIERARCHY_MODE: Final[HierarchyMode] = HierarchyMode.ROUND_ROBIN
+RESTING_STEM_ID: Final[int] = -1
+RESTING_FRAME_COST: Final[float] = 0.0
+STEM_ACTIVITY_FLOOR: Final[float] = TEMPORAL_LEVEL_FLOOR * min(MIXER_PULSE, MIXER_TRIANGLE, MIXER_NOISE)
+
# Execution
MAX_WORKERS: Final[int] = 6
diff --git a/src/sampletones_core/constants/enums.py b/src/sampletones_core/constants/enums.py
index 9d887fe9c..a586a05c5 100644
--- a/src/sampletones_core/constants/enums.py
+++ b/src/sampletones_core/constants/enums.py
@@ -5,13 +5,13 @@
from typing import Dict, Final, List, Literal
-class LibraryGeneratorName(StrEnum):
+class GeneratorName(StrEnum):
PULSE = "pulse"
TRIANGLE = "triangle"
NOISE = "noise"
-class GeneratorName(StrEnum):
+class ChannelName(StrEnum):
PULSE1 = "pulse1"
PULSE2 = "pulse2"
TRIANGLE = "triangle"
@@ -23,7 +23,7 @@ def capitalized(self) -> str:
return spaced_value.capitalize()
@classmethod
- def items(cls) -> List[GeneratorName]:
+ def items(cls) -> List[ChannelName]:
return [cls.PULSE1, cls.PULSE2, cls.TRIANGLE, cls.NOISE]
@@ -73,6 +73,11 @@ class SelectorName(StrEnum):
VITERBI = "viterbi"
+class HierarchyMode(StrEnum):
+ ROUND_ROBIN = "round_robin"
+ STRICT = "strict"
+
+
class SpectrumMethod(StrEnum):
FFT = "fft"
LOG_SPACED_FFT = "logfft"
@@ -84,28 +89,28 @@ class CQTWindow(StrEnum):
RECTANGULAR = "rectangular"
-GENERATOR_ABBREVIATIONS: Final[Dict[GeneratorName, Literal["P", "p", "T", "N"]]] = {
- GeneratorName.PULSE1: "P",
- GeneratorName.PULSE2: "p",
- GeneratorName.TRIANGLE: "T",
- GeneratorName.NOISE: "N",
+CHANNEL_ABBREVIATIONS: Final[Dict[ChannelName, Literal["P", "p", "T", "N"]]] = {
+ ChannelName.PULSE1: "P",
+ ChannelName.PULSE2: "p",
+ ChannelName.TRIANGLE: "T",
+ ChannelName.NOISE: "N",
}
-GENERATOR_ABBREVIATION_TO_NAME: Final[Dict[str, GeneratorName]] = {
- abbreviation: name for name, abbreviation in GENERATOR_ABBREVIATIONS.items()
+CHANNEL_ABBREVIATION_TO_NAME: Final[Dict[str, ChannelName]] = {
+ abbreviation: name for name, abbreviation in CHANNEL_ABBREVIATIONS.items()
}
-GENERATOR_ABBREVIATION_PATTERN: Final[str] = rf"^[{''.join(GENERATOR_ABBREVIATIONS.values())}]+$"
+CHANNEL_ABBREVIATION_PATTERN: Final[str] = rf"^[{''.join(CHANNEL_ABBREVIATIONS.values())}]+$"
-DEFAULT_GENERATORS: Final[List[GeneratorName]] = [
- GeneratorName.PULSE1,
- GeneratorName.TRIANGLE,
- GeneratorName.NOISE,
+DEFAULT_CHANNELS: Final[List[ChannelName]] = [
+ ChannelName.PULSE1,
+ ChannelName.TRIANGLE,
+ ChannelName.NOISE,
]
-def abbreviate_generator_names(generator_names: List[GeneratorName]) -> str:
- return "".join(GENERATOR_ABBREVIATIONS[name] for name in generator_names)
+def abbreviate_channel_names(channel_names: List[ChannelName]) -> str:
+ return "".join(CHANNEL_ABBREVIATIONS[name] for name in channel_names)
diff --git a/src/sampletones_core/constants/field_aliases.py b/src/sampletones_core/constants/field_aliases.py
index e8b2b1acd..e64b284c5 100644
--- a/src/sampletones_core/constants/field_aliases.py
+++ b/src/sampletones_core/constants/field_aliases.py
@@ -6,6 +6,6 @@
"ws": "window_size",
"tg": "transformation_gamma",
"sm": "spectrum_method",
- "gn": "generators",
+ "gn": "channels",
"ch": "config_hash",
}
diff --git a/src/sampletones_core/constants/general.py b/src/sampletones_core/constants/general.py
index aca1463b8..1f7bc6aa0 100644
--- a/src/sampletones_core/constants/general.py
+++ b/src/sampletones_core/constants/general.py
@@ -3,17 +3,16 @@
# Pitches and frequencies
APU_CLOCK: Final[float] = 1789773.0
-LIMIT_MIN_PITCH: Final[int] = 24
+TIMER_CYCLE_DIVIDER: Final[int] = 16
+MIN_TIMER: Final[int] = 1
+MAX_TIMER: Final[int] = 0x7FF
MIN_PITCH: Final[int] = 33
MAX_PITCH: Final[int] = 119
-LIMIT_MAX_PITCH: Final[int] = 127
PITCH_RANGE: Final[int] = MAX_PITCH - MIN_PITCH
-MIN_FREQUENCY: Final[float] = APU_CLOCK / 0x8000
-MAX_FREQUENCY: Final[float] = APU_CLOCK / 0x10
+MIN_FREQUENCY: Final[float] = APU_CLOCK / (TIMER_CYCLE_DIVIDER * (MAX_TIMER + 1))
+MAX_FREQUENCY: Final[float] = APU_CLOCK / TIMER_CYCLE_DIVIDER
-A4_FREQUENCY: Final[float] = 440.0
-A4_PITCH: Final[int] = 69
NOTE_NAMES: Tuple[str, ...] = (
"C-",
"C#",
@@ -29,11 +28,15 @@
"B-",
)
-MIN_TRANSPOSE: Final[int] = -24
-MAX_TRANSPOSE: Final[int] = 36
+MAX_TRANSPOSE: Final[int] = PITCH_RANGE
+MIN_TRANSPOSE: Final[int] = -PITCH_RANGE
ARPEGGIO_MIN: Final[int] = -128
ARPEGGIO_MAX: Final[int] = 127
+PITCH_BEND_MIN: Final[int] = -128
+PITCH_BEND_MAX: Final[int] = 127
+HI_PITCH_FACTOR: Final[int] = 16
+
# Instruction parameters ranges
SILENT_VOLUME: Final[int] = 0
diff --git a/src/sampletones_core/data/model.py b/src/sampletones_core/data/model.py
index 82ca27112..479f33b04 100644
--- a/src/sampletones_core/data/model.py
+++ b/src/sampletones_core/data/model.py
@@ -106,9 +106,27 @@ def deserialize_inner(
validation: Optional[Callback] = None,
fast: bool = True,
) -> Self:
+ """The model a serialized payload describes, filling in what the payload leaves out.
+
+ A payload written before a field existed states nothing for it, and a field carrying a
+ default states what it means to say nothing, so the default is what the field takes. This
+ is what lets a model grow a field while every file already written keeps loading.
+
+ Args:
+ data: The serialized fields.
+ validation: A check run over each value as it is read.
+ fast: Whether to construct without re-running validation.
+
+ Returns:
+ Self: The model the payload describes.
+ """
field_values: SerializedData = {}
for field_name, field_info in cls.model_fields.items():
annotation = field_info.annotation
+ if field_name not in data and not field_info.is_required():
+ field_values[field_name] = field_info.get_default(call_default_factory=True)
+ continue
+
raw = data.get(field_name)
value = cls._unpack_value(
raw,
@@ -147,6 +165,17 @@ def _pack_value(self, value: Any, annotation: Any, field_name: str) -> Any:
if get_origin(annotation) is list:
return self._pack_list(value, field_name)
+ if get_origin(annotation) is tuple:
+ item_class = get_args(annotation)[0]
+ return [
+ self._pack_value(
+ item,
+ item_class,
+ field_name,
+ )
+ for item in value
+ ]
+
if issubclass(annotation, DataModel):
return value.serialize_inner()
@@ -205,6 +234,10 @@ def _unpack_value(
fast,
)
+ if get_origin(annotation) is tuple:
+ item_class = get_args(annotation)[0]
+ return tuple(cls._unpack_value(item, item_class, field_name, validation, fast) for item in raw)
+
if issubclass(annotation, DataModel):
return annotation.deserialize_inner(raw, validation, fast=fast)
@@ -233,6 +266,12 @@ def _pack_list(
if all(isinstance(model, DataModel) for model in collection):
return [model.serialize_inner() for model in collection]
+ if all(isinstance(value, (int, float, bool)) for value in collection):
+ return list(collection)
+
+ if all(isinstance(value, list) for value in collection):
+ return [self._pack_list(value, field_name) for value in collection]
+
raise SerializationError(
f"Unsupported list element type {type(collection[0])} or mixed types for field '{field_name}'"
)
@@ -247,6 +286,19 @@ def _unpack_list(
fast: bool = True,
) -> List[Any]:
origin = get_origin(element_class)
+ if origin is list:
+ nested_element_class = get_args(element_class)[0]
+ return [
+ cls._unpack_list(
+ item,
+ field_name,
+ nested_element_class,
+ validation,
+ fast,
+ )
+ for item in raw_list
+ ]
+
if origin is not None:
raise DeserializationError(f"Generics are not supported for field '{field_name}'")
@@ -266,6 +318,9 @@ def _unpack_list(
if issubclass(element_class, (str, StrEnum)):
return [cls._deserialize_string(item, element_class) for item in raw_list]
+ if issubclass(element_class, (int, float, bool)):
+ return [element_class(item) for item in raw_list]
+
raise DeserializationError(f"Unsupported vector element type: {element_class} for field '{field_name}'")
def _pack_array(self, array: Array, field_name: str) -> bytes:
diff --git a/src/sampletones_core/exporters/__init__.py b/src/sampletones_core/exporters/__init__.py
index f8a841f07..d6a35b8dc 100644
--- a/src/sampletones_core/exporters/__init__.py
+++ b/src/sampletones_core/exporters/__init__.py
@@ -1,13 +1,14 @@
from .exporter import Exporter
-from .feature import Features
+from .feature import Features, playing_channels
from .implementation.noise import NoiseExporter
from .implementation.pulse import PulseExporter
from .implementation.triangle import TriangleExporter
-from .maps import GENERATOR_NAME_TO_EXPORTER_MAP, INSTRUCTION_TO_EXPORTER_MAP
+from .maps import CHANNEL_TO_EXPORTER_MAP, INSTRUCTION_TO_EXPORTER_MAP
+from .tonal import TonalExporter
from .types import ExporterClass, ExporterT, ExporterTypeUnion, ExporterUnion
__all__ = [
- "GENERATOR_NAME_TO_EXPORTER_MAP",
+ "CHANNEL_TO_EXPORTER_MAP",
"INSTRUCTION_TO_EXPORTER_MAP",
"Exporter",
"ExporterClass",
@@ -17,5 +18,7 @@
"Features",
"NoiseExporter",
"PulseExporter",
+ "TonalExporter",
"TriangleExporter",
+ "playing_channels",
]
diff --git a/src/sampletones_core/exporters/exporter.py b/src/sampletones_core/exporters/exporter.py
index 154e0d61c..2c8ea5080 100644
--- a/src/sampletones_core/exporters/exporter.py
+++ b/src/sampletones_core/exporters/exporter.py
@@ -1,22 +1,58 @@
from abc import ABC, abstractmethod
-from typing import ClassVar, Dict, Generic, Iterable, List, Optional, Union, cast
-
-import numpy as np
+from typing import ClassVar, Dict, Generic, Iterable, List, Optional, Tuple, Union
from sampletones_core.constants.enums import FeatureKey
from sampletones_core.features import CHANNEL_FEATURE_DEFAULTS
+from sampletones_core.features.envelope import Envelope
from sampletones_core.generators import GeneratorTypeUnion
from sampletones_core.instructions import (
InstructionFields,
InstructionT,
InstructionTypeUnion,
)
-from sampletones_core.types.feature import FeatureMap
-from sampletones_shared.utils.arrays import hold, trim
from .feature import Features
+def _sounding_length(volume: Tuple[int, ...]) -> Optional[int]:
+ """How far every dimension is kept: one frame past the last the volume sounds at.
+
+ The frame past the last audible one is what releases a note, so a reconstruction that runs
+ out while still loud is kept together with the silence its generator wrote after it.
+
+ Args:
+ volume: The per-tick volume the channel wrote.
+
+ Returns:
+ Optional[int]: The item count to keep, or ``None`` where the channel never sounds and
+ every dimension stands as written.
+ """
+ audible = [index for index, level in enumerate(volume) if level]
+ if not audible:
+ return None
+
+ return audible[-1] + 2
+
+
+def _trimmed(items: Tuple[int, ...]) -> Tuple[int, ...]:
+ """One dimension with its repeated tail dropped, keeping one instance of its final value.
+
+ Args:
+ items: The values the dimension wrote.
+
+ Returns:
+ Tuple[int, ...]: The values up to and including the last one that changes.
+ """
+ if not items:
+ return items
+
+ length = len(items)
+ while length > 1 and items[length - 1] == items[length - 2]:
+ length -= 1
+
+ return items[:length]
+
+
class Exporter(ABC, Generic[InstructionT]):
"""
Converts between a channel's instruction sequence and FamiTracker features.
@@ -33,8 +69,9 @@ class Exporter(ABC, Generic[InstructionT]):
_ATTRIBUTE_MAP: ClassVar[Dict[FeatureKey, InstructionFields]]
+ @classmethod
def to_features(
- self,
+ cls,
instructions: List[InstructionT],
initial_pitch: int,
held_features: Iterable[FeatureKey],
@@ -43,7 +80,9 @@ def to_features(
An instruction states every dimension of its frame, so the dimensions the instrument
leaves to the channel are named alongside the sequence and come back with empty
- envelopes: what the frames carry for them is the value the channel held.
+ envelopes: what the frames carry for them is the value the channel held. Every dimension
+ is trimmed to the span ending just after the last audible frame, which is what leaves a
+ reconstruction resting at silence once its volume runs out.
Args:
instructions: The channel's per-frame instructions.
@@ -53,50 +92,45 @@ def to_features(
Returns:
Features: The envelope representation of the sequence.
"""
- feature_map = self.get_feature_map(instructions, initial_pitch)
- features = self.from_feature_map_to_features(feature_map)
- features.leave_to_channel(held_features)
- return features
+ written = cls.read_envelopes(instructions, initial_pitch)
+ sounding = _sounding_length(written.get(FeatureKey.VOLUME, ()))
+ envelopes = {
+ feature_key: Envelope[int](items=_trimmed(items[:sounding])) for feature_key, items in written.items()
+ }
+ return Features.of(initial_pitch, envelopes).leave_to_channel(held_features)
- @staticmethod
- def from_feature_map_to_features(feature_map: FeatureMap) -> Features:
- """Builds trimmed :class:`Features` from a raw feature map.
+ @classmethod
+ @abstractmethod
+ def unstated_features(cls, instructions: List[InstructionT]) -> Tuple[FeatureKey, ...]:
+ """The dimensions this stream leaves to the channel rather than writing itself.
- Trims each envelope to the span ending just after the last audible frame, so
- trailing silence is dropped from every dimension together.
+ A stream states every dimension its frames carry a choice for. Where a dimension carries
+ nothing but the value a channel holds from the start of a song, the stream has made no
+ choice at all, and saying so leaves the dimension empty rather than pinning it to a value
+ it would sound at anyway.
Args:
- feature_map: The raw per-dimension arrays.
+ instructions: The channel's per-frame instructions.
Returns:
- Features: The trimmed features.
+ Tuple[FeatureKey, ...]: The dimensions the channel governs, in dimension order.
"""
- features = Features.from_feature_map(feature_map)
- last_nonzero_volume_index: Optional[int] = None
- try:
- last_nonzero_volume_index = features.volume.nonzero()[0][-1] + 2
- except IndexError:
- pass
-
- for key, value in features.items():
- if isinstance(value, np.ndarray):
- array = value[:last_nonzero_volume_index]
- trimmed_value = trim(array)
- features[key] = trimmed_value
-
- return features
@classmethod
@abstractmethod
- def get_feature_map(cls, instructions: List[InstructionT], initial_pitch: int) -> FeatureMap:
- """Extracts the raw per-dimension feature arrays from an instruction sequence.
+ def read_envelopes(
+ cls,
+ instructions: List[InstructionT],
+ initial_pitch: int,
+ ) -> Dict[FeatureKey, Tuple[int, ...]]:
+ """The per-tick values each dimension this channel reads carries.
Args:
instructions: The channel's per-frame instructions.
- initial_pitch: Reference pitch the arpeggio envelope is measured against.
+ initial_pitch: Reference pitch the arpeggio values are measured against.
Returns:
- FeatureMap: The per-dimension arrays for this channel.
+ Dict[FeatureKey, Tuple[int, ...]]: The values per dimension the generator offers.
"""
@classmethod
@@ -134,28 +168,18 @@ def from_features(cls, features: Features) -> List[InstructionT]:
List[InstructionT]: The reconstructed per-frame instructions.
"""
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)
+ envelopes = features.envelopes
instructions: List[InstructionT] = []
- for index in range(max_length):
+ for index in range(features.frame_count):
instruction_dictionary: Dict[str, Union[bool, int]] = {}
- for key, array in envelopes.items():
- attribute = cls._remap_feature_key(key)
+ for feature_key, envelope in envelopes.items():
+ attribute = cls._remap_feature_key(feature_key)
if not attribute:
continue
- instruction_dictionary[attribute] = int(
- hold(
- array,
- index,
- default=CHANNEL_FEATURE_DEFAULTS[key],
- )
- )
+ item = envelope.at(index)
+ instruction_dictionary[attribute] = item if item is not None else CHANNEL_FEATURE_DEFAULTS[feature_key]
instructions.append(cls._features_dictionary_to_instruction(instruction_dictionary, initial_pitch))
@@ -186,10 +210,8 @@ def feature_values(
if not instruction.on:
return {FeatureKey.VOLUME: 0}
- feature_map = cls.get_feature_map([instruction], initial_pitch)
- return {
- key: int(value[0]) for key, value in feature_map.items() if isinstance(value, np.ndarray) and value.size
- }
+ written = cls.read_envelopes([instruction], initial_pitch)
+ return {feature_key: items[0] for feature_key, items in written.items() if items}
@classmethod
def instruction_from_values(
@@ -248,9 +270,6 @@ def _infer_instruction_on(dictionary: Dict[str, Union[bool, int]]) -> bool:
@classmethod
def _remap_feature_key(cls, feature_key: FeatureKey) -> Optional[InstructionFields]:
- if not hasattr(cls, "_ATTRIBUTE_MAP"):
- raise NotImplementedError("Subclasses must define _ATTRIBUTE_MAP")
-
return cls._ATTRIBUTE_MAP.get(feature_key)
@classmethod
diff --git a/src/sampletones_core/exporters/feature.py b/src/sampletones_core/exporters/feature.py
index 54f1bba48..9016955fa 100644
--- a/src/sampletones_core/exporters/feature.py
+++ b/src/sampletones_core/exporters/feature.py
@@ -1,143 +1,168 @@
-from __future__ import annotations
-
-from typing import Any, Dict, Iterable, List, Optional, Tuple, cast
-
-import numpy as np
-from pydantic import BaseModel, ConfigDict
-
-from sampletones_core.constants.enums import FeatureKey
-from sampletones_core.types.feature import FeatureMap, FeatureValue
-
-
-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 arpeggio
- envelope is relative to. A dimension the channel offers is an array, ``None`` for
- one it lacks; an array of no items marks a dimension the instrument leaves to the
- channel, which keeps the value it holds. The mapping interface (subscript, ``get``,
- ``keys``/``items``/``values``, ``in``) exposes the envelopes keyed by
- :class:`FeatureKey`, listing the dimensions the channel offers.
-
- Attributes:
- 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.
- hi_pitch: Fine-pitch envelope, or ``None`` when unused.
- duty_cycle: Duty-cycle envelope, or ``None`` when unused.
- """
-
- model_config = ConfigDict(arbitrary_types_allowed=True)
-
- initial_pitch: int
- volume: np.ndarray
- arpeggio: np.ndarray
- pitch: Optional[np.ndarray]
- hi_pitch: Optional[np.ndarray]
- duty_cycle: Optional[np.ndarray]
-
- @classmethod
- def from_feature_map(
- cls,
- feature_map: FeatureMap,
- ) -> Features:
- """Builds features from a raw feature map.
-
- Args:
- feature_map: The per-dimension arrays keyed by :class:`FeatureKey`.
-
- Returns:
- Features: The features carrying those envelopes.
- """
- return cls(
- initial_pitch=cast(int, feature_map[FeatureKey.INITIAL_PITCH]),
- volume=cast(np.ndarray, feature_map[FeatureKey.VOLUME]),
- arpeggio=cast(np.ndarray, feature_map[FeatureKey.ARPEGGIO]),
- pitch=cast(Optional[np.ndarray], feature_map.get(FeatureKey.PITCH)),
- hi_pitch=cast(Optional[np.ndarray], feature_map.get(FeatureKey.HI_PITCH)),
- duty_cycle=cast(Optional[np.ndarray], feature_map.get(FeatureKey.DUTY_CYCLE)),
- )
-
- @property
- def feature_map(self) -> Dict[FeatureKey, Optional[FeatureValue]]:
- return {
- FeatureKey.INITIAL_PITCH: self.initial_pitch,
- FeatureKey.VOLUME: self.volume,
- FeatureKey.ARPEGGIO: self.arpeggio,
- FeatureKey.PITCH: self.pitch,
- FeatureKey.HI_PITCH: self.hi_pitch,
- FeatureKey.DUTY_CYCLE: self.duty_cycle,
- }
-
- def __getitem__(self, feature_key: FeatureKey) -> FeatureValue:
- value = self.feature_map.get(feature_key)
- if value is None:
- raise KeyError(feature_key)
- return value
-
- def __setitem__(self, feature_key: FeatureKey, value: FeatureValue) -> None:
- if feature_key == FeatureKey.INITIAL_PITCH:
- if not isinstance(value, int):
- raise TypeError(f"Expected int for {feature_key}, got {type(value)}")
- else:
- if not isinstance(value, np.ndarray):
- raise TypeError(f"Expected np.ndarray for {feature_key}, got {type(value)}")
-
- setattr(self, feature_key.name.lower(), value)
-
- def __contains__(self, feature_key: FeatureKey) -> bool:
- return feature_key in self.feature_map and self.feature_map[feature_key] is not None
-
- def get(self, feature_key: FeatureKey, default: Optional[Any] = None) -> Optional[FeatureValue]:
- return self.feature_map.get(feature_key, default)
-
- def keys(self) -> List[FeatureKey]:
- return [key for key, value in self.feature_map.items() if value is not None]
-
- def items(self) -> List[Tuple[FeatureKey, FeatureValue]]:
- return [(key, value) for key, value in self.feature_map.items() if value is not None]
-
- def values(self) -> List[FeatureValue]:
- return [value for value in self.feature_map.values() if value is not None]
-
- @property
- 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)
-
- @property
- def has_frames(self) -> bool:
- """Whether the envelopes describe a frame, which is what a channel plays.
-
- Every dimension left to the channel leaves an instrument describing nothing, so this
- is what tells a channel that sounds from one that stands by: an export writes the
- instruments that have frames, and the driver stores only those.
- """
- return self.frame_count > 0
-
- @property
- def held_features(self) -> Tuple[FeatureKey, ...]:
- """The dimensions the channel governs, whose envelopes carry no item.
-
- An instrument writes the dimensions it describes and leaves the rest to the channel,
- which keeps the value it already holds for as long as the instrument sounds. These
- are the dimensions it leaves, listed in the order the model declares them.
- """
- return tuple(key for key, value in self.items() if isinstance(value, np.ndarray) and value.size == 0)
-
- def leave_to_channel(self, feature_keys: Iterable[FeatureKey]) -> None:
- """Empties the envelope of each named dimension the channel offers, so the channel governs it.
-
- The dimensions a channel offers are the ones it can hold a value for, so the record acts
- on those and leaves the shape of the features as the channel defines it.
-
- Args:
- feature_keys: The dimensions the instrument leaves to the channel.
- """
- for feature_key in feature_keys:
- if feature_key in self:
- self[feature_key] = np.array([], dtype=np.int8)
+from __future__ import annotations
+
+from typing import Dict, FrozenSet, Iterable, Mapping, Optional, Tuple
+
+from pydantic import BaseModel, ConfigDict
+
+from sampletones_core.constants.enums import ChannelName, FeatureKey
+from sampletones_core.features.envelope import Envelope
+
+FeatureEnvelopes = Mapping[FeatureKey, Envelope[int]]
+
+
+class Features(BaseModel):
+ """The per-dimension envelopes describing one FamiTracker instrument.
+
+ Each field is one dimension the channel reads — volume, arpeggio, pitch, hi-pitch and duty
+ cycle — carrying the values it writes per tick together with the item they repeat from, beside
+ the ``initial_pitch`` the arpeggio is measured against. A dimension the generator offers is an
+ envelope, ``None`` for one it lacks; an envelope of no items marks a dimension the instrument
+ leaves to the channel, which keeps the value it holds.
+
+ Attributes:
+ initial_pitch: Reference pitch the arpeggio envelope is measured against.
+ volume: Volume envelope.
+ arpeggio: Arpeggio (relative pitch) envelope.
+ pitch: Divider-bend envelope at one step per unit, or ``None`` where the generator
+ lacks the dimension.
+ hi_pitch: Divider-bend envelope at sixteen steps per unit, or ``None`` where the
+ generator lacks the dimension.
+ duty_cycle: Duty-cycle envelope, or ``None`` where the generator lacks the dimension.
+ """
+
+ model_config = ConfigDict(frozen=True)
+
+ initial_pitch: int
+ volume: Envelope[int]
+ arpeggio: Envelope[int]
+ pitch: Optional[Envelope[int]]
+ hi_pitch: Optional[Envelope[int]]
+ duty_cycle: Optional[Envelope[int]]
+
+ @classmethod
+ def of(
+ cls,
+ initial_pitch: int,
+ envelopes: FeatureEnvelopes,
+ ) -> Features:
+ """The features a channel's dimensions describe, leaving out the ones it lacks.
+
+ Args:
+ initial_pitch: Reference pitch the arpeggio envelope is measured against.
+ envelopes: The dimensions the generator offers, keyed by the feature they carry.
+
+ Returns:
+ Features: Those dimensions, with the generator's missing ones absent.
+ """
+ return cls(
+ initial_pitch=initial_pitch,
+ volume=envelopes.get(FeatureKey.VOLUME, Envelope[int]()),
+ arpeggio=envelopes.get(FeatureKey.ARPEGGIO, Envelope[int]()),
+ pitch=envelopes.get(FeatureKey.PITCH),
+ hi_pitch=envelopes.get(FeatureKey.HI_PITCH),
+ duty_cycle=envelopes.get(FeatureKey.DUTY_CYCLE),
+ )
+
+ @property
+ def envelopes(self) -> Dict[FeatureKey, Envelope[int]]:
+ """The dimensions this channel offers, keyed by the feature each carries."""
+ offered = {
+ FeatureKey.VOLUME: self.volume,
+ FeatureKey.ARPEGGIO: self.arpeggio,
+ FeatureKey.PITCH: self.pitch,
+ FeatureKey.HI_PITCH: self.hi_pitch,
+ FeatureKey.DUTY_CYCLE: self.duty_cycle,
+ }
+ return {feature_key: envelope for feature_key, envelope in offered.items() if envelope is not None}
+
+ def envelope(self, feature_key: FeatureKey) -> Envelope[int]:
+ """The dimension one feature names.
+
+ Args:
+ feature_key: The dimension read.
+
+ Returns:
+ Envelope[int]: Its values and the item they repeat from.
+
+ Raises:
+ KeyError: If the channel's generator lacks that dimension.
+ """
+ return self.envelopes[feature_key]
+
+ def offers(self, feature_key: FeatureKey) -> bool:
+ """Whether the channel's generator reads this dimension at all."""
+ return feature_key in self.envelopes
+
+ def with_envelope(self, feature_key: FeatureKey, envelope: Envelope[int]) -> Features:
+ """These features with one dimension replaced.
+
+ Args:
+ feature_key: The dimension written.
+ envelope: What that dimension now carries; empty items leave it to the channel.
+
+ Returns:
+ Features: The features carrying ``envelope`` for ``feature_key``.
+
+ Raises:
+ KeyError: If the channel's generator lacks that dimension.
+ """
+ if not self.offers(feature_key):
+ raise KeyError(feature_key)
+
+ return self.model_copy(update={feature_key.value: envelope})
+
+ def leave_to_channel(self, feature_keys: Iterable[FeatureKey]) -> Features:
+ """These features with each named dimension emptied, so the channel governs it.
+
+ The dimensions a channel offers are the ones it can hold a value for, so this acts on
+ those and leaves the shape of the features as the channel defines it.
+
+ Args:
+ feature_keys: The dimensions the instrument leaves to the channel.
+
+ Returns:
+ Features: The features with those dimensions carrying no item.
+ """
+ emptied = {feature_key.value: Envelope[int]() for feature_key in feature_keys if self.offers(feature_key)}
+ return self.model_copy(update=emptied)
+
+ @property
+ def frame_count(self) -> int:
+ """The frame count the envelopes describe, taken from the longest populated dimension."""
+ return max((len(envelope.items) for envelope in self.envelopes.values()), default=0)
+
+ @property
+ def has_frames(self) -> bool:
+ """Whether the envelopes describe a frame, which is what a channel plays.
+
+ Every dimension left to the channel leaves an instrument describing nothing, so this
+ is what tells a channel that sounds from one that stands by: an export writes the
+ instruments that have frames, and the driver stores only those.
+ """
+ return self.frame_count > 0
+
+ @property
+ def held_features(self) -> Tuple[FeatureKey, ...]:
+ """The dimensions the channel governs, whose envelopes carry no item.
+
+ An instrument writes the dimensions it describes and leaves the rest to the channel,
+ which keeps the value it already holds for as long as the instrument sounds. These
+ are the dimensions it leaves, listed in the order the model declares them.
+ """
+ return tuple(feature_key for feature_key, envelope in self.envelopes.items() if not envelope.written)
+
+
+def playing_channels(channels: Mapping[ChannelName, Features]) -> FrozenSet[ChannelName]:
+ """The channels among ``channels`` whose envelopes describe a frame.
+
+ Describing a frame is what puts a channel in play: those are the ones an export writes, the
+ ones a footprint measures and the ones a panel offers, while the rest stand by. Stating the
+ rule once has every reader of a channel's envelopes agree on which of them sound.
+
+ Args:
+ channels: The envelopes each channel plays.
+
+ Returns:
+ FrozenSet[ChannelName]: The channels that sound.
+ """
+ return frozenset(channel_name for channel_name, features in channels.items() if features.has_frames)
diff --git a/src/sampletones_core/exporters/implementation/noise.py b/src/sampletones_core/exporters/implementation/noise.py
index 6beec1535..5de6b8175 100644
--- a/src/sampletones_core/exporters/implementation/noise.py
+++ b/src/sampletones_core/exporters/implementation/noise.py
@@ -1,7 +1,5 @@
from typing import ClassVar, Dict, List, Tuple, Union
-import numpy as np
-
from sampletones_core.constants.enums import FeatureKey
from sampletones_core.constants.general import NUM_PERIODS
from sampletones_core.generators import GeneratorTypeUnion, NoiseGenerator
@@ -10,7 +8,6 @@
InstructionTypeUnion,
NoiseInstruction,
)
-from sampletones_core.types.feature import FeatureMap
from ..exporter import Exporter
@@ -65,19 +62,29 @@ def derive_initial_pitch(
return initial_period
@classmethod
- def get_feature_map(
+ def unstated_features(cls, instructions: List[NoiseInstruction]) -> Tuple[FeatureKey, ...]:
+ """Every dimension the noise channel reads is one its frames choose, so it states them all.
+
+ Args:
+ instructions: The channel's per-frame instructions.
+
+ Returns:
+ Tuple[FeatureKey, ...]: No dimension, since the stream writes each one it offers.
+ """
+ return ()
+
+ @classmethod
+ def read_envelopes(
cls,
instructions: List[NoiseInstruction],
initial_pitch: int,
- ) -> FeatureMap:
+ ) -> Dict[FeatureKey, Tuple[int, ...]]:
_, periods, volumes, duty_cycles = cls.extract_data(instructions)
- arpeggio = (np.array(periods) - initial_pitch) % NUM_PERIODS
return {
- 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),
+ FeatureKey.VOLUME: tuple(volumes),
+ FeatureKey.ARPEGGIO: tuple((period - initial_pitch) % NUM_PERIODS for period in periods),
+ FeatureKey.DUTY_CYCLE: tuple(duty_cycles),
}
@classmethod
diff --git a/src/sampletones_core/exporters/implementation/pulse.py b/src/sampletones_core/exporters/implementation/pulse.py
index 987810263..0504919c1 100644
--- a/src/sampletones_core/exporters/implementation/pulse.py
+++ b/src/sampletones_core/exporters/implementation/pulse.py
@@ -1,7 +1,5 @@
from typing import ClassVar, Dict, List, Tuple, Union
-import numpy as np
-
from sampletones_core.constants.enums import FeatureKey
from sampletones_core.constants.general import MIN_PITCH
from sampletones_core.exporters.implementation.utils import center_pitch
@@ -11,16 +9,17 @@
InstructionTypeUnion,
PulseInstruction,
)
-from sampletones_core.types.feature import FeatureMap
from sampletones_core.utils.frequencies import is_pitch_valid
-from ..exporter import Exporter
+from ..tonal import TonalExporter
-class PulseExporter(Exporter[PulseInstruction]):
+class PulseExporter(TonalExporter[PulseInstruction]):
_ATTRIBUTE_MAP: ClassVar[Dict[FeatureKey, InstructionFields]] = {
FeatureKey.VOLUME: "volume",
FeatureKey.ARPEGGIO: "pitch",
+ FeatureKey.PITCH: "detune",
+ FeatureKey.HI_PITCH: "coarse_detune",
FeatureKey.DUTY_CYCLE: "duty_cycle",
}
@@ -64,19 +63,18 @@ def derive_initial_pitch(cls, instructions: List[PulseInstruction]) -> int:
return center_pitch(first_pitch, pitches)
@classmethod
- def get_feature_map(
+ def read_envelopes(
cls,
instructions: List[PulseInstruction],
initial_pitch: int,
- ) -> FeatureMap:
+ ) -> Dict[FeatureKey, Tuple[int, ...]]:
_, pitches, volumes, duty_cycles = cls.extract_data(instructions)
- arpeggio = np.array(pitches) - initial_pitch
return {
- 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),
+ FeatureKey.VOLUME: tuple(volumes),
+ FeatureKey.ARPEGGIO: tuple(pitch - initial_pitch for pitch in pitches),
+ FeatureKey.DUTY_CYCLE: tuple(duty_cycles),
+ **cls.read_bends(instructions),
}
@classmethod
@@ -94,6 +92,7 @@ def _features_dictionary_to_instruction(
pitch=pitch,
volume=int(dictionary[cls._ATTRIBUTE_MAP[FeatureKey.VOLUME]]),
duty_cycle=int(dictionary[cls._ATTRIBUTE_MAP[FeatureKey.DUTY_CYCLE]]),
+ **cls.bend_fields(dictionary),
)
@classmethod
diff --git a/src/sampletones_core/exporters/implementation/triangle.py b/src/sampletones_core/exporters/implementation/triangle.py
index 1c7eb7d4e..dafb96366 100644
--- a/src/sampletones_core/exporters/implementation/triangle.py
+++ b/src/sampletones_core/exporters/implementation/triangle.py
@@ -1,7 +1,5 @@
from typing import ClassVar, Dict, List, Tuple, Union
-import numpy as np
-
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_pitch
@@ -11,16 +9,17 @@
InstructionTypeUnion,
TriangleInstruction,
)
-from sampletones_core.types.feature import FeatureMap
from sampletones_core.utils.frequencies import is_pitch_valid
-from ..exporter import Exporter
+from ..tonal import TonalExporter
-class TriangleExporter(Exporter[TriangleInstruction]):
+class TriangleExporter(TonalExporter[TriangleInstruction]):
_ATTRIBUTE_MAP: ClassVar[Dict[FeatureKey, InstructionFields]] = {
FeatureKey.VOLUME: "volume",
FeatureKey.ARPEGGIO: "pitch",
+ FeatureKey.PITCH: "detune",
+ FeatureKey.HI_PITCH: "coarse_detune",
}
@classmethod
@@ -62,18 +61,17 @@ def derive_initial_pitch(
return center_pitch(first_pitch, pitches)
@classmethod
- def get_feature_map(
+ def read_envelopes(
cls,
instructions: List[TriangleInstruction],
initial_pitch: int,
- ) -> FeatureMap:
+ ) -> Dict[FeatureKey, Tuple[int, ...]]:
_, pitches, volumes = cls.extract_data(instructions)
- arpeggio = np.array(pitches) - initial_pitch
return {
- FeatureKey.INITIAL_PITCH: initial_pitch,
- FeatureKey.VOLUME: np.array(volumes).astype(np.int8),
- FeatureKey.ARPEGGIO: arpeggio.astype(np.int8),
+ FeatureKey.VOLUME: tuple(volumes),
+ FeatureKey.ARPEGGIO: tuple(pitch - initial_pitch for pitch in pitches),
+ **cls.read_bends(instructions),
}
@classmethod
@@ -89,6 +87,7 @@ def _features_dictionary_to_instruction(
return TriangleInstruction(
on=cls._infer_instruction_on(dictionary),
pitch=pitch,
+ **cls.bend_fields(dictionary),
)
@classmethod
diff --git a/src/sampletones_core/exporters/implementation/utils.py b/src/sampletones_core/exporters/implementation/utils.py
index b6d8182ad..d932520cb 100644
--- a/src/sampletones_core/exporters/implementation/utils.py
+++ b/src/sampletones_core/exporters/implementation/utils.py
@@ -1,7 +1,9 @@
-from typing import List
+from typing import Callable, List, Sequence, Tuple
import numpy as np
+from sampletones_core.instructions import TonalInstruction
+
def center_pitch(
initial_pitch: int,
@@ -30,3 +32,39 @@ def center_pitch(
min_value = np.min(array)
mean_value = (max_value + min_value) // 2
return int(initial_pitch + mean_value)
+
+
+def held_across_rests(
+ instructions: Sequence[TonalInstruction],
+ read: Callable[[TonalInstruction], int],
+ default: int,
+) -> Tuple[int, ...]:
+ """One value per frame, holding what the last sounding frame stated across the rests.
+
+ A rest states no pitch of its own, so the value a channel carries through it is the one it
+ last sounded; the rests before the first sounding frame take that frame's value, so a
+ dimension reads the same however a recording opens. This is the rule ``extract_data`` reads a
+ contour by, stated once for every dimension a note carries.
+
+ Args:
+ instructions: The channel's per-frame instructions.
+ read: What the dimension takes from one sounding frame.
+ default: The value a channel that never sounds carries.
+
+ Returns:
+ Tuple[int, ...]: One value per instruction.
+ """
+ values: List[int] = []
+ opening: int = default
+ seen = False
+
+ for instruction in instructions:
+ if instruction.on:
+ opening = read(instruction)
+ if not seen:
+ seen = True
+ values = [opening for _ in values]
+
+ values.append(opening)
+
+ return tuple(values)
diff --git a/src/sampletones_core/exporters/lengths.py b/src/sampletones_core/exporters/lengths.py
deleted file mode 100644
index fc5ff1511..000000000
--- a/src/sampletones_core/exporters/lengths.py
+++ /dev/null
@@ -1,100 +0,0 @@
-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 _limited_length(length: int, limit: Optional[int]) -> int:
- """Brings a length within what the target format stores, reporting what that drops."""
- 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 _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.
- """
- return _limited_length(min(lengths) if loop else max(lengths), limit)
-
-
-def limit_lengths(
- items_by_kind: Dict[EnvelopeKey, Tuple[int, ...]],
- *,
- limit: int,
-) -> Dict[EnvelopeKey, Tuple[int, ...]]:
- """Keeps each dimension's opening items, as many as the target format stores.
-
- Every dimension stands at its own length, which is what a player that sustains an
- exhausted envelope's final value reads: the envelope describes the frames it covers
- and the last value it wrote governs the rest.
-
- Args:
- items_by_kind: The per-dimension item tuples, empty for a dimension the channel
- leaves unused.
- limit: The most items the target format stores.
-
- Returns:
- Dict[EnvelopeKey, Tuple[int, ...]]: The items with every dimension within the limit.
- """
- return {
- kind: items[
- : _limited_length(
- len(items),
- limit,
- )
- ]
- for kind, items in items_by_kind.items()
- }
-
-
-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/maps.py b/src/sampletones_core/exporters/maps.py
index 28ea82bb0..01ad7d210 100644
--- a/src/sampletones_core/exporters/maps.py
+++ b/src/sampletones_core/exporters/maps.py
@@ -1,6 +1,6 @@
from typing import Dict, Type
-from sampletones_core.constants.enums import GeneratorName
+from sampletones_core.constants.enums import ChannelName
from sampletones_core.instructions import (
Instruction,
NoiseInstruction,
@@ -19,9 +19,9 @@
NoiseInstruction: NoiseExporter,
}
-GENERATOR_NAME_TO_EXPORTER_MAP: Dict[GeneratorName, ExporterTypeUnion] = {
- GeneratorName.PULSE1: PulseExporter,
- GeneratorName.PULSE2: PulseExporter,
- GeneratorName.TRIANGLE: TriangleExporter,
- GeneratorName.NOISE: NoiseExporter,
+CHANNEL_TO_EXPORTER_MAP: Dict[ChannelName, ExporterTypeUnion] = {
+ ChannelName.PULSE1: PulseExporter,
+ ChannelName.PULSE2: PulseExporter,
+ ChannelName.TRIANGLE: TriangleExporter,
+ ChannelName.NOISE: NoiseExporter,
}
diff --git a/src/sampletones_core/exporters/naming.py b/src/sampletones_core/exporters/naming.py
index a4871f6a3..387f6cbfd 100644
--- a/src/sampletones_core/exporters/naming.py
+++ b/src/sampletones_core/exporters/naming.py
@@ -1,8 +1,8 @@
-from sampletones_core.constants.enums import GeneratorName
+from sampletones_core.constants.enums import ChannelName
-def instrument_slice_name(base_name: str, generator: GeneratorName) -> str:
- """Names one generator slice of a reconstruction.
+def instrument_slice_name(base_name: str, channel: ChannelName) -> str:
+ """Names one channel 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
@@ -11,9 +11,9 @@ def instrument_slice_name(base_name: str, generator: GeneratorName) -> str:
Args:
base_name: The name of the reconstruction or sample the slice came from.
- generator: The NES channel the slice covers.
+ channel: The NES channel the slice covers.
Returns:
- str: The slice's name, of the form ``base (generator)``.
+ str: The slice's name, of the form ``base (channel)``.
"""
- return f"{base_name} ({generator})"
+ return f"{base_name} ({channel})"
diff --git a/src/sampletones_core/exporters/slices.py b/src/sampletones_core/exporters/slices.py
deleted file mode 100644
index 2d3618693..000000000
--- a/src/sampletones_core/exporters/slices.py
+++ /dev/null
@@ -1,87 +0,0 @@
-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 that plays, 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[generator]
- if not features.has_frames:
- continue
-
- yield SampleSlice(
- index=index,
- sample=sample,
- generator=generator,
- features=features,
- )
- index += 1
diff --git a/src/sampletones_core/exporters/slices/__init__.py b/src/sampletones_core/exporters/slices/__init__.py
new file mode 100644
index 000000000..a763fbac2
--- /dev/null
+++ b/src/sampletones_core/exporters/slices/__init__.py
@@ -0,0 +1,33 @@
+from .instrument import (
+ FIRST_INSTRUMENT_INDEX,
+ InstrumentEntry,
+ InstrumentSlot,
+ InstrumentTable,
+ instrument_entries,
+ iterate_instrument_entries,
+ sample_instrument_entries,
+ voice_instrument_entries,
+)
+from .voice import (
+ VoiceSlice,
+ instrument_slices,
+ iterate_voice_slices,
+ sample_slices,
+ voice_slices,
+)
+
+__all__ = [
+ "FIRST_INSTRUMENT_INDEX",
+ "InstrumentEntry",
+ "InstrumentSlot",
+ "InstrumentTable",
+ "VoiceSlice",
+ "instrument_entries",
+ "instrument_slices",
+ "iterate_instrument_entries",
+ "iterate_voice_slices",
+ "sample_instrument_entries",
+ "sample_slices",
+ "voice_instrument_entries",
+ "voice_slices",
+]
diff --git a/src/sampletones_core/exporters/slices/instrument.py b/src/sampletones_core/exporters/slices/instrument.py
new file mode 100644
index 000000000..1a16eec5a
--- /dev/null
+++ b/src/sampletones_core/exporters/slices/instrument.py
@@ -0,0 +1,168 @@
+from dataclasses import dataclass
+from typing import Dict, Final, Iterator, Optional, Tuple
+
+from sampletones_core.constants.enums import ChannelName
+from sampletones_core.exporters.feature import Features
+from sampletones_core.exporters.naming import instrument_slice_name
+from sampletones_core.project.project import Project
+from sampletones_core.project.voices.instrument import Instrument
+from sampletones_core.project.voices.sample import Sample
+from sampletones_core.project.voices.voice import VoiceUnion, voice_channels
+
+FIRST_INSTRUMENT_INDEX: Final[int] = 0
+
+
+@dataclass(frozen=True)
+class InstrumentSlot:
+ """Where a row naming a voice on one channel lands: the instrument it plays and its reference."""
+
+ index: int
+ initial_pitch: int
+
+
+InstrumentTable = Dict[Tuple[str, ChannelName], InstrumentSlot]
+
+
+@dataclass(frozen=True)
+class InstrumentEntry:
+ """One instrument an export writes, and the channels whose rows reach it.
+
+ A sample's channels each carry frames of their own, so each becomes an instrument answering
+ for that channel alone. An instrument carries one set of envelopes every channel reads, so it
+ becomes one instrument answering for every channel it sounds on, each against its own root —
+ which is the instrument model FamiTracker itself uses.
+
+ Attributes:
+ index: Position the instrument takes in the exported table.
+ voice_id: The voice a row names to reach it.
+ name: The name the tracker lists it by.
+ features: The envelopes written into it.
+ channel: The channel ``features`` are stated for, or ``None`` where they are the one set
+ every channel reads and belong to no channel in particular.
+ slots: Per channel it answers for, the table position and the reference that channel reads.
+ """
+
+ index: int
+ voice_id: str
+ name: str
+ features: Features
+ channel: Optional[ChannelName]
+ slots: Dict[ChannelName, InstrumentSlot]
+
+
+def sample_instrument_entries(
+ sample: Sample,
+ *,
+ start_index: int,
+) -> Iterator[InstrumentEntry]:
+ """The instruments a recording contributes: one per channel its conversion found frames for.
+
+ Each carries the frames of one channel alone, so it is named after that channel and answers
+ for it by itself.
+
+ Args:
+ sample: The sample being exported.
+ start_index: The slot the first of its instruments is numbered under.
+
+ Yields:
+ InstrumentEntry: One instrument per playing channel.
+ """
+ features_by_channel = sample.reconstruction.export()
+ index = start_index
+ for channel in ChannelName.items():
+ features = features_by_channel[channel]
+ if not features.has_frames:
+ continue
+
+ yield InstrumentEntry(
+ index=index,
+ voice_id=sample.id,
+ name=instrument_slice_name(sample.name, channel),
+ features=features,
+ channel=channel,
+ slots={
+ channel: InstrumentSlot(
+ index=index,
+ initial_pitch=features.initial_pitch,
+ ),
+ },
+ )
+ index += 1
+
+
+def instrument_entries(
+ instrument: Instrument,
+ *,
+ start_index: int,
+) -> Iterator[InstrumentEntry]:
+ """The instrument a hand-written voice contributes, which is its one envelope set.
+
+ Every channel it sounds on reaches that set, each against the root it reads, so the entry
+ answers for all of them and states its envelopes for none of them in particular.
+
+ Args:
+ instrument: The voice being exported.
+ start_index: The slot the instrument is numbered under.
+
+ Yields:
+ InstrumentEntry: The one instrument, where its envelopes make a frame anywhere.
+ """
+ channels = voice_channels(instrument)
+ if not channels:
+ return
+
+ yield InstrumentEntry(
+ index=start_index,
+ voice_id=instrument.id,
+ name=instrument.name,
+ features=instrument.instrument_features(),
+ channel=None,
+ slots={
+ channel: InstrumentSlot(
+ index=start_index,
+ initial_pitch=instrument.reference(channel),
+ )
+ for channel in channels
+ },
+ )
+
+
+def voice_instrument_entries(
+ voice: VoiceUnion,
+ *,
+ start_index: int,
+) -> Iterator[InstrumentEntry]:
+ """The instruments an export writes for one voice, numbered from ``start_index``.
+
+ This is the whole rule for what a voice contributes to an export, so a module writing every
+ voice and a file writing one read the same thing: a reader is offered exactly the instruments
+ a module would have held.
+
+ Args:
+ voice: The voice being exported.
+ start_index: The slot the first of its instruments is numbered under.
+
+ Yields:
+ InstrumentEntry: Each instrument alongside the channels whose rows reach it.
+ """
+ match voice:
+ case Sample():
+ yield from sample_instrument_entries(voice, start_index=start_index)
+ case Instrument():
+ yield from instrument_entries(voice, start_index=start_index)
+
+
+def iterate_instrument_entries(project: Project) -> Iterator[InstrumentEntry]:
+ """Walks the instruments an export writes, numbered in voice order then channel order.
+
+ Args:
+ project: The project whose voices are exported.
+
+ Yields:
+ InstrumentEntry: Each instrument alongside the channels whose rows reach it.
+ """
+ index = FIRST_INSTRUMENT_INDEX
+ for voice in project.voices:
+ for entry in voice_instrument_entries(voice, start_index=index):
+ yield entry
+ index += 1
diff --git a/src/sampletones_core/exporters/slices/voice.py b/src/sampletones_core/exporters/slices/voice.py
new file mode 100644
index 000000000..4d0ec2129
--- /dev/null
+++ b/src/sampletones_core/exporters/slices/voice.py
@@ -0,0 +1,105 @@
+from dataclasses import dataclass
+from typing import Iterator, Tuple
+
+from sampletones_core.constants.enums import ChannelName
+from sampletones_core.exporters.feature import Features
+from sampletones_core.exporters.naming import instrument_slice_name
+from sampletones_core.project.project import Project
+from sampletones_core.project.voices.instrument import Instrument
+from sampletones_core.project.voices.sample import Sample
+from sampletones_core.project.voices.voice import VoiceUnion, voice_channels
+
+
+@dataclass(frozen=True)
+class VoiceSlice:
+ """What one channel of one voice plays, in the envelope terms every backend reads.
+
+ Attributes:
+ voice: The voice the slice came from.
+ channel: The NES channel the slice covers.
+ features: The per-dimension envelopes describing the slice.
+ """
+
+ voice: VoiceUnion
+ channel: ChannelName
+ features: Features
+
+ @property
+ def instrument_name(self) -> str:
+ """The exported instrument's name, naming both its voice and its channel."""
+ return instrument_slice_name(self.voice.name, self.channel)
+
+ @property
+ def key(self) -> Tuple[str, ChannelName]:
+ """The identity a pattern row references the slice by."""
+ return (self.voice.id, self.channel)
+
+
+def sample_slices(sample: Sample) -> Iterator[VoiceSlice]:
+ """What each channel of a recording plays, one slice per channel its conversion found frames for.
+
+ Args:
+ sample: The sample being exported.
+
+ Yields:
+ VoiceSlice: What each of its playing channels plays.
+ """
+ features_by_channel = sample.reconstruction.export()
+ for channel in ChannelName.items():
+ features = features_by_channel[channel]
+ if features.has_frames:
+ yield VoiceSlice(
+ voice=sample,
+ channel=channel,
+ features=features,
+ )
+
+
+def instrument_slices(instrument: Instrument) -> Iterator[VoiceSlice]:
+ """What each channel of a hand-written voice plays, each reading the one envelope set its way.
+
+ Args:
+ instrument: The voice being exported.
+
+ Yields:
+ VoiceSlice: What each channel its envelopes make a frame for plays.
+ """
+ for channel in voice_channels(instrument):
+ yield VoiceSlice(
+ voice=instrument,
+ channel=channel,
+ features=instrument.features(channel),
+ )
+
+
+def voice_slices(voice: VoiceUnion) -> Iterator[VoiceSlice]:
+ """What each channel of one voice plays, whichever kind the voice is.
+
+ Args:
+ voice: The voice being exported.
+
+ Yields:
+ VoiceSlice: What each of its playing channels plays.
+ """
+ match voice:
+ case Sample():
+ yield from sample_slices(voice)
+ case Instrument():
+ yield from instrument_slices(voice)
+
+
+def iterate_voice_slices(project: Project) -> Iterator[VoiceSlice]:
+ """Walks what every channel of every voice plays, in voice order then channel order.
+
+ A voice contributes one slice per channel it sounds on, so a sample yields one to four and an
+ instrument yields one per channel its envelopes make a frame for. Each voice is read once, so a
+ caller reads a reconstruction's envelopes at a single cost.
+
+ Args:
+ project: The project whose voices are exported.
+
+ Yields:
+ VoiceSlice: What each channel of each voice plays.
+ """
+ for voice in project.voices:
+ yield from voice_slices(voice)
diff --git a/src/sampletones_core/exporters/tonal.py b/src/sampletones_core/exporters/tonal.py
new file mode 100644
index 000000000..90661ebfe
--- /dev/null
+++ b/src/sampletones_core/exporters/tonal.py
@@ -0,0 +1,89 @@
+from abc import ABC
+from typing import Dict, List, Tuple, TypeVar, Union
+
+from sampletones_core.constants.enums import FeatureKey
+from sampletones_core.features import BEND_FEATURES, CHANNEL_FEATURE_DEFAULTS
+from sampletones_core.instructions import TonalInstruction
+
+from .exporter import Exporter
+from .implementation.utils import held_across_rests
+
+TonalInstructionT = TypeVar("TonalInstructionT", bound=TonalInstruction)
+
+
+class TonalExporter(Exporter[TonalInstructionT], ABC):
+ """The reading the channels that name a note share: the bend each frame carries.
+
+ A pulse or triangle frame states its note and, beside it, how far off that note's own divider
+ it sounds. Both dimensions are read the way a contour is read — a rest carries what the last
+ sounding frame stated — so a bend survives a rest exactly as a pitch does.
+ """
+
+ @classmethod
+ def read_bends(
+ cls,
+ instructions: List[TonalInstructionT],
+ ) -> Dict[FeatureKey, Tuple[int, ...]]:
+ """The per-tick values the two bend dimensions carry.
+
+ Args:
+ instructions: The channel's per-frame instructions.
+
+ Returns:
+ Dict[FeatureKey, Tuple[int, ...]]: One value per frame for each bend dimension.
+ """
+ return {
+ FeatureKey.PITCH: held_across_rests(
+ instructions,
+ lambda instruction: instruction.detune,
+ CHANNEL_FEATURE_DEFAULTS[FeatureKey.PITCH],
+ ),
+ FeatureKey.HI_PITCH: held_across_rests(
+ instructions,
+ lambda instruction: instruction.coarse_detune,
+ CHANNEL_FEATURE_DEFAULTS[FeatureKey.HI_PITCH],
+ ),
+ }
+
+ @classmethod
+ def unstated_features(cls, instructions: List[TonalInstructionT]) -> Tuple[FeatureKey, ...]:
+ """The bend dimensions this stream makes no use of.
+
+ A stream that never leaves its notes' own dividers has made no bend, which is what a
+ channel governing the dimension sounds anyway, so it leaves both dimensions empty.
+
+ Args:
+ instructions: The channel's per-frame instructions.
+
+ Returns:
+ Tuple[FeatureKey, ...]: The bend dimensions the channel governs, in dimension order.
+ """
+ written = cls.read_bends(instructions)
+ return tuple(
+ feature_key
+ for feature_key, items in written.items()
+ if all(item == CHANNEL_FEATURE_DEFAULTS[feature_key] for item in items)
+ )
+
+ @classmethod
+ def bend_fields(cls, dictionary: Dict[str, Union[bool, int]]) -> Dict[str, int]:
+ """The bend a row of feature values states, as the fields an instruction takes.
+
+ A row naming neither dimension describes a channel that governs both, and a channel holds
+ no bend from the start of a song, so the frame sounds at its note's own divider.
+
+ Args:
+ dictionary: One frame's feature values, keyed by the instruction field each fills.
+
+ Returns:
+ Dict[str, int]: The bend fields to build the instruction with.
+ """
+ return {
+ cls._ATTRIBUTE_MAP[feature_key]: int(
+ dictionary.get(
+ cls._ATTRIBUTE_MAP[feature_key],
+ CHANNEL_FEATURE_DEFAULTS[feature_key],
+ )
+ )
+ for feature_key in BEND_FEATURES
+ }
diff --git a/src/sampletones_core/exporters/truncation.py b/src/sampletones_core/exporters/truncation.py
index 726819e5a..575a29626 100644
--- a/src/sampletones_core/exporters/truncation.py
+++ b/src/sampletones_core/exporters/truncation.py
@@ -18,23 +18,6 @@ class EnvelopeTruncation:
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,
diff --git a/src/sampletones_core/exports/__init__.py b/src/sampletones_core/exports/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/src/sampletones_core/trackers/artifact.py b/src/sampletones_core/exports/artifact.py
similarity index 100%
rename from src/sampletones_core/trackers/artifact.py
rename to src/sampletones_core/exports/artifact.py
diff --git a/src/sampletones_core/trackers/backend.py b/src/sampletones_core/exports/backend.py
similarity index 63%
rename from src/sampletones_core/trackers/backend.py
rename to src/sampletones_core/exports/backend.py
index 6f3a6d779..a3f9049b1 100644
--- a/src/sampletones_core/trackers/backend.py
+++ b/src/sampletones_core/exports/backend.py
@@ -1,27 +1,33 @@
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 (
+from sampletones_core.exports.artifact import ExportArtifact
+from sampletones_core.exports.format import ExportFormat
+from sampletones_core.exports.progress import ExportReporter
+from sampletones_core.exports.request import (
InstrumentExport,
ProjectExport,
SampleExport,
)
-from sampletones_core.trackers.scope import ExportScope
+from sampletones_core.exports.scope import ExportScope
+from sampletones_shared.utils.progress import silent_reporter
-class TrackerBackend(Protocol):
- """Writes the application's work in the file format one tracker reads.
+class ExportBackend(Protocol):
+ """Writes the application's work in one of the file formats it exports to.
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.
+
+ A write reports itself as it runs and asks its reporter whether the answer is still
+ wanted, which is what lets a caller watch a long format and withdraw one. A caller with
+ nothing to tell passes :func:`silent_reporter` and hears back the file alone.
"""
@property
- def tracker_format(self) -> TrackerFormat:
+ def export_format(self) -> ExportFormat:
"""The format this backend writes."""
@property
@@ -42,17 +48,20 @@ def write_instrument(
self,
destination: Path,
request: InstrumentExport,
+ report: ExportReporter = silent_reporter,
) -> ExportArtifact:
- """Writes one generator slice.
+ """Writes one channel slice.
Args:
destination: The file to write.
request: The slice to write.
+ report: Hears each stage of the write, and answers whether it goes on.
Returns:
ExportArtifact: The paths written and what the format's limits left out.
Raises:
+ OperationCanceled: If ``report`` withdraws the write.
OSError: If the destination cannot be written.
"""
@@ -60,19 +69,22 @@ def write_sample(
self,
destination: Path,
request: SampleExport,
+ report: ExportReporter = silent_reporter,
) -> ExportArtifact:
- """Writes every generator slice of one reconstruction.
+ """Writes every channel 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.
+ report: Hears each stage of the write, and answers whether it goes on.
Returns:
ExportArtifact: The paths written and what the format's limits left out.
Raises:
+ OperationCanceled: If ``report`` withdraws the write.
OSError: If the destination cannot be written.
"""
@@ -80,17 +92,20 @@ def write_project(
self,
destination: Path,
request: ProjectExport,
+ report: ExportReporter = silent_reporter,
) -> ExportArtifact:
"""Writes a whole composition.
Args:
destination: The file to write.
request: The project to write.
+ report: Hears each stage of the write, and answers whether it goes on.
Returns:
ExportArtifact: The paths written and what the format's limits left out.
Raises:
+ OperationCanceled: If ``report`` withdraws the write.
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/exports/extensions.py
similarity index 58%
rename from src/sampletones_core/trackers/extensions.py
rename to src/sampletones_core/exports/extensions.py
index 7333ea06c..1ee0dc2d3 100644
--- a/src/sampletones_core/trackers/extensions.py
+++ b/src/sampletones_core/exports/extensions.py
@@ -1,18 +1,18 @@
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
+from sampletones_core.exports.backend import ExportBackend
+from sampletones_core.exports.format import ExportFormat
+from sampletones_core.exports.scope import ExportScope
def format_for_extension(
- backends: Mapping[TrackerFormat, TrackerBackend],
+ backends: Mapping[ExportFormat, ExportBackend],
scope: ExportScope,
extension: str,
-) -> Optional[TrackerFormat]:
+) -> Optional[ExportFormat]:
"""The format whose ``scope`` files carry ``extension``.
- The destination the user names decides which tracker the export is written for, so the
+ The destination the user names decides which format the export is written in, so the
extension it ends in resolves to a format here. Case folds, letting a destination typed
in capitals reach the same backend.
@@ -22,12 +22,12 @@ def format_for_extension(
extension: The extension the chosen destination carries, leading dot included.
Returns:
- Optional[TrackerFormat]: The format claiming ``extension``, or ``None`` when no
+ Optional[ExportFormat]: 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():
+ for export_format, backend in backends.items():
if scope in backend.supported_scopes and backend.extension(scope).casefold() == wanted:
- return tracker_format
+ return export_format
return None
diff --git a/src/sampletones_core/exports/format.py b/src/sampletones_core/exports/format.py
new file mode 100644
index 000000000..3b893f269
--- /dev/null
+++ b/src/sampletones_core/exports/format.py
@@ -0,0 +1,10 @@
+from enum import StrEnum
+
+
+class ExportFormat(StrEnum):
+ """A file format the application exports to, and the backend that writes it."""
+
+ FAMITRACKER = "famitracker"
+ BITPHASE = "bitphase"
+ BITPHASE_PRESET = "bitphase_preset"
+ NSF = "nsf"
diff --git a/src/sampletones_core/exports/implementation/__init__.py b/src/sampletones_core/exports/implementation/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/src/sampletones_core/trackers/implementation/bitphase.py b/src/sampletones_core/exports/implementation/bitphase.py
similarity index 60%
rename from src/sampletones_core/trackers/implementation/bitphase.py
rename to src/sampletones_core/exports/implementation/bitphase.py
index 38b3561eb..ba5df71b6 100644
--- a/src/sampletones_core/trackers/implementation/bitphase.py
+++ b/src/sampletones_core/exports/implementation/bitphase.py
@@ -1,6 +1,16 @@
from pathlib import Path
-from typing import FrozenSet, List
-
+from typing import Final, FrozenSet, List
+
+from sampletones_core.exports.artifact import ExportArtifact
+from sampletones_core.exports.format import ExportFormat
+from sampletones_core.exports.progress import ExportReporter, announce
+from sampletones_core.exports.request import (
+ InstrumentExport,
+ ProjectExport,
+ SampleExport,
+)
+from sampletones_core.exports.scope import ExportScope
+from sampletones_core.exports.stage import ExportStage
from sampletones_core.formats.bitphase.btp import write_btp
from sampletones_core.formats.bitphase.builder import (
instrument_to_bitphase,
@@ -8,17 +18,16 @@
sample_to_bitphase,
)
from sampletones_core.formats.bitphase.preset import instrument_to_preset, write_preset
-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.paths.extensions import EXT_FILE_BITPHASE, EXT_FILE_JSON
+from sampletones_shared.utils.progress import silent_reporter
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
+NOTHING_WRITTEN: Final[int] = 0
+ONE_FILE: Final[int] = 1
class BitphaseBackend:
@@ -31,38 +40,50 @@ class BitphaseBackend:
"""
@property
- def tracker_format(self) -> TrackerFormat:
- return TrackerFormat.BITPHASE
+ def export_format(self) -> ExportFormat:
+ return ExportFormat.BITPHASE
@property
def supported_scopes(self) -> FrozenSet[ExportScope]:
return DOCUMENT_SCOPES
- def extension(self, scope: ExportScope) -> str:
+ def extension(self, scope: ExportScope) -> str: # pylint: disable=unused-argument
return EXT_FILE_BITPHASE
def write_instrument(
self,
destination: Path,
request: InstrumentExport,
+ report: ExportReporter = silent_reporter,
) -> ExportArtifact:
+ announce(report, ExportStage.WRITING, NOTHING_WRITTEN, ONE_FILE)
write_btp(destination, instrument_to_bitphase(request))
+ announce(report, ExportStage.WRITING, ONE_FILE, ONE_FILE)
+
return ExportArtifact(paths=(destination,), truncation=WHOLE_ENVELOPE)
def write_sample(
self,
destination: Path,
request: SampleExport,
+ report: ExportReporter = silent_reporter,
) -> ExportArtifact:
+ announce(report, ExportStage.WRITING, NOTHING_WRITTEN, ONE_FILE)
write_btp(destination, sample_to_bitphase(request))
+ announce(report, ExportStage.WRITING, ONE_FILE, ONE_FILE)
+
return ExportArtifact(paths=(destination,), truncation=WHOLE_ENVELOPE)
def write_project(
self,
destination: Path,
request: ProjectExport,
+ report: ExportReporter = silent_reporter,
) -> ExportArtifact:
+ announce(report, ExportStage.WRITING, NOTHING_WRITTEN, ONE_FILE)
write_btp(destination, project_to_bitphase(request.project))
+ announce(report, ExportStage.WRITING, ONE_FILE, ONE_FILE)
+
return ExportArtifact(paths=(destination,), truncation=WHOLE_ENVELOPE)
@@ -71,40 +92,54 @@ class BitphasePresetBackend:
The panel reads one instrument per file into the slot the user has selected, so a
whole reconstruction lands as a set of them beside the chosen destination, one file
- per generator slice named after the instrument. A preset carries rows alone, so its
+ per channel 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
+ def export_format(self) -> ExportFormat:
+ return ExportFormat.BITPHASE_PRESET
@property
def supported_scopes(self) -> FrozenSet[ExportScope]:
return PRESET_SCOPES
- def extension(self, scope: ExportScope) -> str:
+ def extension(self, scope: ExportScope) -> str: # pylint: disable=unused-argument
return EXT_FILE_JSON
def write_instrument(
self,
destination: Path,
request: InstrumentExport,
+ report: ExportReporter = silent_reporter,
) -> ExportArtifact:
+ announce(report, ExportStage.WRITING, NOTHING_WRITTEN, ONE_FILE)
write_preset(destination, instrument_to_preset(request))
+ announce(report, ExportStage.WRITING, ONE_FILE, ONE_FILE)
+
return ExportArtifact(paths=(destination,), truncation=WHOLE_ENVELOPE)
def write_sample(
self,
destination: Path,
request: SampleExport,
+ report: ExportReporter = silent_reporter,
) -> ExportArtifact:
destination.parent.mkdir(parents=True, exist_ok=True)
+ written = len(request.instruments)
+ announce(report, ExportStage.WRITING, NOTHING_WRITTEN, written)
+
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)
+ for index, instrument in enumerate(request.instruments, start=ONE_FILE):
+ filepath = destination.with_name(
+ get_filename(
+ instrument.name,
+ EXT_FILE_JSON,
+ )
+ )
+ paths.extend(self.write_instrument(filepath, instrument, silent_reporter).paths)
+ announce(report, ExportStage.WRITING, index, written)
return ExportArtifact(paths=tuple(paths), truncation=WHOLE_ENVELOPE)
@@ -112,6 +147,7 @@ def write_project(
self,
destination: Path,
request: ProjectExport,
+ report: ExportReporter = silent_reporter,
) -> ExportArtifact:
"""Reports that a preset holds one instrument.
diff --git a/src/sampletones_core/trackers/implementation/famitracker.py b/src/sampletones_core/exports/implementation/famitracker.py
similarity index 63%
rename from src/sampletones_core/trackers/implementation/famitracker.py
rename to src/sampletones_core/exports/implementation/famitracker.py
index dfe9bee04..9346e2365 100644
--- a/src/sampletones_core/trackers/implementation/famitracker.py
+++ b/src/sampletones_core/exports/implementation/famitracker.py
@@ -1,41 +1,45 @@
from pathlib import Path
-from typing import FrozenSet, List, Optional
+from typing import Final, FrozenSet, List, Optional
from sampletones_core.exporters.truncation import EnvelopeTruncation
+from sampletones_core.exports.artifact import ExportArtifact
+from sampletones_core.exports.format import ExportFormat
+from sampletones_core.exports.progress import ExportReporter, announce
+from sampletones_core.exports.request import (
+ InstrumentExport,
+ ProjectExport,
+ SampleExport,
+)
+from sampletones_core.exports.scope import ExportScope
+from sampletones_core.exports.stage import ExportStage
from sampletones_core.formats.famitracker.builder import build_instrument
from sampletones_core.formats.famitracker.export import write_ftm
from sampletones_core.formats.famitracker.instrument import write_fti
+from sampletones_core.formats.famitracker.sequences.features import features_truncation
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.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.paths.extensions import EXT_FILE_INSTRUMENT, EXT_FILE_MODULE
+from sampletones_shared.utils.progress import silent_reporter
from sampletones_shared.utils.system.paths import get_filename
SUPPORTED_SCOPES: FrozenSet[ExportScope] = frozenset(ExportScope)
+NOTHING_WRITTEN: Final[int] = 0
+ONE_FILE: Final[int] = 1
+
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
+ as a set of them beside the chosen destination, one file per channel slice named
after the instrument.
"""
@property
- def tracker_format(self) -> TrackerFormat:
- return TrackerFormat.FAMITRACKER
+ def export_format(self) -> ExportFormat:
+ return ExportFormat.FAMITRACKER
@property
def supported_scopes(self) -> FrozenSet[ExportScope]:
@@ -48,42 +52,46 @@ def write_instrument(
self,
destination: Path,
request: InstrumentExport,
+ report: ExportReporter = silent_reporter,
) -> ExportArtifact:
+ announce(report, ExportStage.WRITING, NOTHING_WRITTEN, ONE_FILE)
instrument = build_instrument(
STANDALONE_INSTRUMENT_INDEX,
request.name,
request.features,
- loop=request.loop,
)
write_fti(destination, instrument)
+ announce(report, ExportStage.WRITING, ONE_FILE, ONE_FILE)
return ExportArtifact(
paths=(destination,),
- truncation=EnvelopeTruncation.measure(
- request.features.frame_count,
- MAX_SEQUENCE_ITEMS,
- ),
+ truncation=features_truncation(request.features),
)
def write_sample(
self,
destination: Path,
request: SampleExport,
+ report: ExportReporter = silent_reporter,
) -> ExportArtifact:
destination.parent.mkdir(parents=True, exist_ok=True)
+ written = len(request.instruments)
+ announce(report, ExportStage.WRITING, NOTHING_WRITTEN, written)
+
paths: List[Path] = []
truncations: List[Optional[EnvelopeTruncation]] = []
- for instrument in request.instruments:
+ for index, instrument in enumerate(request.instruments, start=ONE_FILE):
filepath = destination.with_name(
get_filename(
instrument.name,
EXT_FILE_INSTRUMENT,
)
)
- artifact = self.write_instrument(filepath, instrument)
+ artifact = self.write_instrument(filepath, instrument, silent_reporter)
paths.extend(artifact.paths)
truncations.append(artifact.truncation)
+ announce(report, ExportStage.WRITING, index, written)
return ExportArtifact(
paths=tuple(paths),
@@ -94,6 +102,10 @@ def write_project(
self,
destination: Path,
request: ProjectExport,
+ report: ExportReporter = silent_reporter,
) -> ExportArtifact:
+ announce(report, ExportStage.WRITING, NOTHING_WRITTEN, ONE_FILE)
write_ftm(destination, request.project)
+ announce(report, ExportStage.WRITING, ONE_FILE, ONE_FILE)
+
return ExportArtifact(paths=(destination,), truncation=None)
diff --git a/src/sampletones_core/exports/progress.py b/src/sampletones_core/exports/progress.py
new file mode 100644
index 000000000..572105556
--- /dev/null
+++ b/src/sampletones_core/exports/progress.py
@@ -0,0 +1,45 @@
+from dataclasses import dataclass
+from typing import Callable, Optional
+
+from sampletones_core.exports.stage import ExportStage
+from sampletones_shared.exceptions import OperationCanceled
+
+
+@dataclass(frozen=True)
+class ExportProgress:
+ """How far one stage of an export run has come.
+
+ Attributes:
+ stage: The work the run is in the middle of, which names the unit the counts are in.
+ completed: What the stage has reached so far.
+ total: What the stage counts up to, and ``None`` where the stage runs to a length only
+ the data it reads decides.
+ """
+
+ stage: ExportStage
+ completed: int
+ total: Optional[int]
+
+
+ExportReporter = Callable[[ExportProgress], bool]
+
+
+def announce(
+ report: ExportReporter,
+ stage: ExportStage,
+ completed: int,
+ total: Optional[int],
+) -> None:
+ """Tells a reporter how far a stage has come, and unwinds the run it withdraws.
+
+ Args:
+ report: Hears the stage and answers whether the run goes on.
+ stage: The work the run is in the middle of.
+ completed: What the stage has reached so far.
+ total: What the stage counts up to, and ``None`` where only the data decides.
+
+ Raises:
+ OperationCanceled: If the run is no longer wanted.
+ """
+ if not report(ExportProgress(stage=stage, completed=completed, total=total)):
+ raise OperationCanceled(f"the export was withdrawn while {stage}")
diff --git a/src/sampletones_core/exports/registry.py b/src/sampletones_core/exports/registry.py
new file mode 100644
index 000000000..ec64c7b7b
--- /dev/null
+++ b/src/sampletones_core/exports/registry.py
@@ -0,0 +1,24 @@
+from typing import Dict
+
+from sampletones_core.exports.backend import ExportBackend
+from sampletones_core.exports.format import ExportFormat
+from sampletones_core.exports.implementation.bitphase import BitphaseBackend, BitphasePresetBackend
+from sampletones_core.exports.implementation.famitracker import FamiTrackerBackend
+
+
+def build_tracker_backends() -> Dict[ExportFormat, ExportBackend]:
+ """Builds one backend per tracker format the reconstruction engine writes.
+
+ The composition root calls this once and hands the result to the components that
+ offer a format choice, so a new tracker format reaches the whole application by
+ joining this mapping. A format whose backend stands above the engine — the console
+ player's own — is registered beside these by the composition root itself.
+
+ Returns:
+ Dict[ExportFormat, ExportBackend]: Every tracker backend, keyed by the format it writes.
+ """
+ return {
+ ExportFormat.FAMITRACKER: FamiTrackerBackend(),
+ ExportFormat.BITPHASE: BitphaseBackend(),
+ ExportFormat.BITPHASE_PRESET: BitphasePresetBackend(),
+ }
diff --git a/src/sampletones_core/exports/request.py b/src/sampletones_core/exports/request.py
new file mode 100644
index 000000000..559847d98
--- /dev/null
+++ b/src/sampletones_core/exports/request.py
@@ -0,0 +1,98 @@
+# TODO: split into a subpackage - divide into logical units
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+from typing import Tuple
+
+from sampletones_core.constants.enums import ChannelName
+from sampletones_core.exporters.feature import Features
+from sampletones_core.project.project import Project
+from sampletones_shared.music import Tuning
+
+
+@dataclass(frozen=True)
+class InstrumentExport:
+ """One channel slice of a reconstruction, ready for a backend to write.
+
+ Attributes:
+ name: Name the written instrument carries.
+ channel: The NES channel the slice was reconstructed for.
+ features: The per-dimension envelopes describing the slice.
+ nes_frequency: Rate in Hz the envelopes advance at, one item per tick.
+ tuning: Where concert pitch sat for the reconstruction the slice came from.
+ """
+
+ name: str
+ channel: ChannelName
+ features: Features
+ nes_frequency: int
+ tuning: Tuning
+
+
+@dataclass(frozen=True)
+class InstrumentSource:
+ """One instrument ready to be written, awaiting the name its destination gives it.
+
+ An instrument reaches a file the same way whatever produced it — a channel of the open
+ reconstruction, a channel of a project sample, or a voice written by hand — so each of those
+ answers with this, and one path carries it the rest of the way. The name is left out because
+ the destination states it: whoever saves the file names the instrument the file carries.
+
+ Attributes:
+ channel: The NES channel the envelopes are read for, which a backend sounding them on
+ its own plays them through.
+ features: The per-dimension envelopes describing the instrument.
+ nes_frequency: Rate in Hz the envelopes advance at, one item per tick.
+ tuning: Where concert pitch sits for the envelopes.
+ """
+
+ channel: ChannelName
+ features: Features
+ nes_frequency: int
+ tuning: Tuning
+
+ def named(self, name: str) -> InstrumentExport:
+ """The request a backend writes, under the name its destination gave it.
+
+ Args:
+ name: The name the written instrument carries.
+
+ Returns:
+ InstrumentExport: The instrument, ready for a backend.
+ """
+ return InstrumentExport(
+ name=name,
+ channel=self.channel,
+ features=self.features,
+ nes_frequency=self.nes_frequency,
+ tuning=self.tuning,
+ )
+
+
+@dataclass(frozen=True)
+class SampleExport:
+ """Every channel 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.
+ tuning: Where concert pitch sat for the reconstruction the slices came from.
+ """
+
+ name: str
+ instruments: Tuple[InstrumentExport, ...]
+ nes_frequency: int
+ tuning: Tuning
+
+
+@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/exports/scope.py
similarity index 100%
rename from src/sampletones_core/trackers/scope.py
rename to src/sampletones_core/exports/scope.py
diff --git a/src/sampletones_core/exports/stage.py b/src/sampletones_core/exports/stage.py
new file mode 100644
index 000000000..47916d5f2
--- /dev/null
+++ b/src/sampletones_core/exports/stage.py
@@ -0,0 +1,30 @@
+from enum import StrEnum
+from typing import Final, FrozenSet
+
+
+class ExportStage(StrEnum):
+ """The work an export run is in the middle of, as the progress it reports names it.
+
+ A format writes its file the moment it is handed one, and a format carrying its own player
+ reaches that point through two longer passes: the song is played out tick by tick, and the
+ result is compressed to what the console has room for. Each stage counts in its own unit, so
+ what a report means is read from the stage it names.
+
+ A stage either travels toward what it is measured against or is merely measured against it.
+ Walking arrives at the song's last tick and writing at its last file, so how far each has come
+ is how far it has to go. Compressing ends when the song offers no further phrase that pays for
+ itself, so its bytes are measured against the room the console has and reach it only by
+ overflowing; :data:`TRAVELING_STAGES` is what separates the two.
+ """
+
+ WALKING = "walking"
+ COMPRESSING = "compressing"
+ WRITING = "writing"
+
+
+TRAVELING_STAGES: Final[FrozenSet[ExportStage]] = frozenset(
+ {
+ ExportStage.WALKING,
+ ExportStage.WRITING,
+ }
+)
diff --git a/src/sampletones_core/features/__init__.py b/src/sampletones_core/features/__init__.py
index d14b4b0ce..5b51dd5ac 100644
--- a/src/sampletones_core/features/__init__.py
+++ b/src/sampletones_core/features/__init__.py
@@ -1,29 +1,41 @@
from .spec import (
+ BEND_FEATURES,
CHANNEL_FEATURE_DEFAULTS,
+ CHANNEL_GENERATOR_KIND,
FEATURE_DIMENSION_ORDER,
+ GENERATOR_CHANNEL_KINDS,
GENERATOR_FEATURE_RANGES,
- GENERATOR_KIND,
RESTING_REFERENCE_PERIOD,
RESTING_REFERENCE_PITCH,
FeatureRange,
+ channel_reference,
feature_range,
+ generator_channel,
resting_held_features,
resting_reference,
+ speaks_in_periods,
supported_features,
supports,
+ transposed_reference,
)
__all__ = [
+ "BEND_FEATURES",
"CHANNEL_FEATURE_DEFAULTS",
"FEATURE_DIMENSION_ORDER",
+ "GENERATOR_CHANNEL_KINDS",
"GENERATOR_FEATURE_RANGES",
- "GENERATOR_KIND",
+ "CHANNEL_GENERATOR_KIND",
"RESTING_REFERENCE_PERIOD",
"RESTING_REFERENCE_PITCH",
"FeatureRange",
+ "channel_reference",
"feature_range",
+ "generator_channel",
+ "speaks_in_periods",
"resting_held_features",
"resting_reference",
"supported_features",
"supports",
+ "transposed_reference",
]
diff --git a/src/sampletones_core/features/envelope.py b/src/sampletones_core/features/envelope.py
new file mode 100644
index 000000000..feb60b980
--- /dev/null
+++ b/src/sampletones_core/features/envelope.py
@@ -0,0 +1,143 @@
+from __future__ import annotations
+
+from typing import Generic, Optional, Tuple, TypeVar
+
+from pydantic import BaseModel, ConfigDict, Field, model_validator
+
+from sampletones_core.constants.general import SILENT_VOLUME
+
+ItemT = TypeVar("ItemT")
+
+
+class Envelope(BaseModel, Generic[ItemT]):
+ """One dimension read per tick: the values it writes, and the value they repeat from.
+
+ Each dimension advances on a counter of its own, which is what lets an attack on the volume
+ sit beside a duty cycle that circles every other tick. A dimension reaching its last item
+ holds that value for as long as the note sounds, so a trailing zero on the volume is what
+ releases a note; one stating a loop point circles from that item instead.
+
+ The point travels with the items it indexes, so every operation that reshapes a dimension
+ reshapes both together and one can never be read against a stale other.
+
+ Attributes:
+ items: The value this dimension writes per tick, empty where the channel governs it.
+ loop_point: The item index the dimension repeats from, or ``None`` where it holds its last.
+ """
+
+ model_config = ConfigDict(frozen=True, extra="forbid")
+
+ items: Tuple[ItemT, ...] = ()
+ loop_point: Optional[int] = Field(
+ default=None,
+ ge=0,
+ description="Item index the dimension repeats from, or None where it holds its last item.",
+ )
+
+ @model_validator(mode="after")
+ def _check_loop_point(self) -> Envelope[ItemT]:
+ if self.loop_point is not None and self.loop_point >= len(self.items):
+ raise ValueError(f"loop point {self.loop_point} stands past the {len(self.items)} items written")
+
+ return self
+
+ @property
+ def loops(self) -> bool:
+ """Whether the dimension circles from a point rather than holding its last item."""
+ return self.loop_point is not None
+
+ @property
+ def written(self) -> bool:
+ """Whether the instrument writes this dimension, which takes it out of the channel's own."""
+ return bool(self.items)
+
+ def at(self, tick: int) -> Optional[ItemT]:
+ """The value this dimension holds at a tick of a sounding note.
+
+ Args:
+ tick: Ticks since the note started.
+
+ Returns:
+ Optional[ItemT]: The value written, or ``None`` where the channel governs this dimension.
+ """
+ if not self.items:
+ return None
+
+ if tick < len(self.items):
+ return self.items[tick]
+
+ if self.loop_point is None:
+ return self.items[-1]
+
+ cycle = len(self.items) - self.loop_point
+ return self.items[self.loop_point + (tick - self.loop_point) % cycle]
+
+ def limited(self, limit: int) -> Envelope[ItemT]:
+ """This dimension's opening items, at most ``limit`` of them.
+
+ A point standing past what survives moves to the last item kept, which is the value the
+ dimension would hold there anyway. Whoever states the limit is where it comes from, so a
+ dimension carries whatever length it was written at until a target asks for less.
+
+ Args:
+ limit: The most items to keep.
+
+ Returns:
+ Envelope[ItemT]: The dimension within that limit, with its point kept inside it.
+ """
+ if len(self.items) <= limit:
+ return self
+
+ return self._holding(self.items[:limit])
+
+ def resized(self, length: int) -> Envelope[ItemT]:
+ """This dimension brought to a length, holding its final value where it falls short.
+
+ A format storing one row per tick reads every dimension out of the same row, so a
+ dimension shorter than its siblings holds the value it ended on for the rest of them.
+
+ Args:
+ length: The item count to reach.
+
+ Returns:
+ Envelope[ItemT]: The dimension at that length, with its point kept inside it.
+ """
+ if not self.items or len(self.items) == length:
+ return self
+
+ return self._holding(self.items[:length] + self.items[-1:] * (length - len(self.items)))
+
+ def with_items(self, items: Tuple[ItemT, ...]) -> Envelope[ItemT]:
+ """This dimension carrying different values, repeating from a point inside them.
+
+ A reader redrawing a dimension states the values alone, so the point it already repeats
+ from survives the edit and moves only far enough to stay inside what is written.
+
+ Args:
+ items: The values the dimension now writes.
+
+ Returns:
+ Envelope[ItemT]: The dimension carrying ``items``.
+ """
+ return self._holding(items)
+
+ def _holding(self, items: Tuple[ItemT, ...]) -> Envelope[ItemT]:
+ """This dimension carrying ``items``, with the loop point held inside them."""
+ loop_point = min(self.loop_point, len(items) - 1) if self.loop_point is not None and items else None
+ return type(self)(items=items, loop_point=loop_point)
+
+
+def releases(volume: Envelope[int]) -> bool:
+ """Whether a volume dimension ends by silencing the note, which is what releases it.
+
+ A dimension holds its last item for as long as a note sounds, so one ending at silence goes
+ quiet and stays quiet: that is where the note it belongs to ends. One circling from a loop
+ point never reaches a last item, so it sounds until whoever started it stops asking.
+
+ Args:
+ volume: The volume dimension of the voice being sounded.
+
+ Returns:
+ bool: Whether the note ends where the dimension runs out.
+ """
+ return bool(volume.items) and volume.items[-1] == SILENT_VOLUME and not volume.loops
diff --git a/src/sampletones_core/features/spec.py b/src/sampletones_core/features/spec.py
index dd3c0b0b1..f71aa45ba 100644
--- a/src/sampletones_core/features/spec.py
+++ b/src/sampletones_core/features/spec.py
@@ -1,7 +1,7 @@
from dataclasses import dataclass
-from typing import Dict, Final, List, Tuple
+from typing import Dict, Final, FrozenSet, List, Tuple
-from sampletones_core.constants.enums import FeatureKey, GeneratorName, LibraryGeneratorName
+from sampletones_core.constants.enums import ChannelName, FeatureKey, GeneratorName
from sampletones_core.constants.general import (
ARPEGGIO_MAX,
ARPEGGIO_MIN,
@@ -10,7 +10,10 @@
MAX_PERIOD,
MAX_VOLUME,
NUM_PERIODS,
+ PITCH_BEND_MAX,
+ PITCH_BEND_MIN,
)
+from sampletones_core.utils.frequencies import transpose_period, transpose_pitch
@dataclass(frozen=True)
@@ -28,6 +31,12 @@ class FeatureRange:
)
+BEND_FEATURES: Final[Tuple[FeatureKey, ...]] = (
+ FeatureKey.PITCH,
+ FeatureKey.HI_PITCH,
+)
+
+
CHANNEL_FEATURE_DEFAULTS: Final[Dict[FeatureKey, int]] = {
FeatureKey.VOLUME: MAX_VOLUME,
FeatureKey.ARPEGGIO: 0,
@@ -41,17 +50,21 @@ class FeatureRange:
RESTING_REFERENCE_PERIOD: Final[int] = NUM_PERIODS // 2
-GENERATOR_FEATURE_RANGES: Final[Dict[LibraryGeneratorName, Dict[FeatureKey, FeatureRange]]] = {
- LibraryGeneratorName.PULSE: {
+GENERATOR_FEATURE_RANGES: Final[Dict[GeneratorName, Dict[FeatureKey, FeatureRange]]] = {
+ GeneratorName.PULSE: {
FeatureKey.VOLUME: FeatureRange(0, MAX_VOLUME),
FeatureKey.ARPEGGIO: FeatureRange(ARPEGGIO_MIN, ARPEGGIO_MAX),
+ FeatureKey.PITCH: FeatureRange(PITCH_BEND_MIN, PITCH_BEND_MAX),
+ FeatureKey.HI_PITCH: FeatureRange(PITCH_BEND_MIN, PITCH_BEND_MAX),
FeatureKey.DUTY_CYCLE: FeatureRange(0, MAX_DUTY_CYCLE),
},
- LibraryGeneratorName.TRIANGLE: {
+ GeneratorName.TRIANGLE: {
FeatureKey.VOLUME: FeatureRange(0, MAX_VOLUME),
FeatureKey.ARPEGGIO: FeatureRange(ARPEGGIO_MIN, ARPEGGIO_MAX),
+ FeatureKey.PITCH: FeatureRange(PITCH_BEND_MIN, PITCH_BEND_MAX),
+ FeatureKey.HI_PITCH: FeatureRange(PITCH_BEND_MIN, PITCH_BEND_MAX),
},
- LibraryGeneratorName.NOISE: {
+ GeneratorName.NOISE: {
FeatureKey.VOLUME: FeatureRange(0, MAX_VOLUME),
FeatureKey.ARPEGGIO: FeatureRange(0, MAX_PERIOD),
FeatureKey.DUTY_CYCLE: FeatureRange(0, MAX_NOISE_MODE),
@@ -59,15 +72,101 @@ class FeatureRange:
}
-GENERATOR_KIND: Final[Dict[GeneratorName, LibraryGeneratorName]] = {
- GeneratorName.PULSE1: LibraryGeneratorName.PULSE,
- GeneratorName.PULSE2: LibraryGeneratorName.PULSE,
- GeneratorName.TRIANGLE: LibraryGeneratorName.TRIANGLE,
- GeneratorName.NOISE: LibraryGeneratorName.NOISE,
+CHANNEL_GENERATOR_KIND: Final[Dict[ChannelName, GeneratorName]] = {
+ ChannelName.PULSE1: GeneratorName.PULSE,
+ ChannelName.PULSE2: GeneratorName.PULSE,
+ ChannelName.TRIANGLE: GeneratorName.TRIANGLE,
+ ChannelName.NOISE: GeneratorName.NOISE,
}
+GENERATOR_CHANNEL_KINDS: Final[Dict[GeneratorName, FrozenSet[ChannelName]]] = {
+ GeneratorName.PULSE: frozenset((ChannelName.PULSE1, ChannelName.PULSE2)),
+ GeneratorName.TRIANGLE: frozenset((ChannelName.TRIANGLE,)),
+ GeneratorName.NOISE: frozenset((ChannelName.NOISE,)),
+}
+
+
+def generator_channel(generator_name: GeneratorName) -> ChannelName:
+ """The channel that stands for a generator, which is the first one it drives.
+
+ A generator drives one channel or two, and the two pulse channels read an instrument the same
+ way, so naming a generator names a channel to sound it on. Reading the channels in channel
+ order keeps the answer the same on every call.
+
+ Args:
+ generator_name: The generator being resolved.
+
+ Returns:
+ ChannelName: The channel that generator is heard on.
+ """
+ channels = GENERATOR_CHANNEL_KINDS[generator_name]
+ return next(channel_name for channel_name in ChannelName.items() if channel_name in channels)
+
+
+def speaks_in_periods(channel_name: ChannelName) -> bool:
+ """Whether this channel reads a pitch-like value as a noise period rather than a semitone.
+
+ The noise channel selects one of sixteen periods where the others name a note, so every rule
+ that reads a pitch — a reference, a note name, a transpose — turns on this one answer.
+
+ Args:
+ channel_name: The channel being read.
+
+ Returns:
+ bool: Whether the channel speaks in noise periods.
+ """
+ return CHANNEL_GENERATOR_KIND[channel_name] is GeneratorName.NOISE
+
+
+def channel_reference(
+ channel_name: ChannelName,
+ *,
+ pitch: int,
+ period: int,
+) -> int:
+ """Which of a pitch-and-period pair a channel measures its arpeggio against.
+
+ An arpeggio envelope is a semitone offset on the tonal channels and a period offset on noise,
+ so a reference is stated as both and the channel picks the one it reads. Every voice states
+ its reference this way, which is what lets one voice be started on any channel.
+
+ Args:
+ channel_name: The channel reading the reference.
+ pitch: The reference a tonal channel measures against.
+ period: The reference the noise channel measures against.
+
+ Returns:
+ int: The reference this channel reads.
+ """
+ return period if speaks_in_periods(channel_name) else pitch
+
+
+def transposed_reference(
+ channel_name: ChannelName,
+ reference: int,
+ transpose: int,
+) -> int:
+ """Where a voice sounds on one channel once a row's transpose has moved it.
+
+ This is the pitch the channel plays, so a grid printing a note and a channel sounding one
+ arrive at the same value: a tonal channel is held inside the range it plays, and the noise
+ channel walks around the sixteen periods the hardware offers.
+
+ Args:
+ channel_name: The channel sounding the voice.
+ reference: The value the voice is measured against on this channel.
+ transpose: The semitones the row has reached.
+
+ Returns:
+ int: The pitch, or the period, the channel sounds.
+ """
+ if speaks_in_periods(channel_name):
+ return transpose_period(reference, transpose)
+
+ return transpose_pitch(reference, transpose)
+
-def resting_reference(generator_name: GeneratorName) -> int:
+def resting_reference(channel_name: ChannelName) -> int:
"""The reference an arpeggio envelope is measured against while a channel describes no frame.
A channel with no frames still carries a reference, since the first envelope given to it
@@ -75,20 +174,20 @@ def resting_reference(generator_name: GeneratorName) -> int:
audible note, and on a noise period between the extremes.
Args:
- generator_name: The channel whose resting reference is read.
+ channel_name: The channel whose resting reference is read.
Returns:
int: The pitch a tonal channel rests at, or the period the noise channel rests at.
"""
- match GENERATOR_KIND[generator_name]:
- case LibraryGeneratorName.NOISE:
- return RESTING_REFERENCE_PERIOD
- case _:
- return RESTING_REFERENCE_PITCH
+ return channel_reference(
+ channel_name,
+ pitch=RESTING_REFERENCE_PITCH,
+ period=RESTING_REFERENCE_PERIOD,
+ )
def resting_held_features(
- generator_name: GeneratorName,
+ channel_name: ChannelName,
) -> Tuple[FeatureKey, ...]:
"""The dimensions a channel governs while it describes no frame.
@@ -97,27 +196,27 @@ def resting_held_features(
one edited down to empty envelopes.
Args:
- generator_name: The channel whose resting record is read.
+ channel_name: The channel whose resting record is read.
Returns:
Tuple[FeatureKey, ...]: The dimensions the channel offers, in dimension order.
"""
- return tuple(supported_features(GENERATOR_KIND[generator_name]))
+ return tuple(supported_features(CHANNEL_GENERATOR_KIND[channel_name]))
def supported_features(
- kind: LibraryGeneratorName,
+ kind: GeneratorName,
) -> List[FeatureKey]:
ranges = GENERATOR_FEATURE_RANGES[kind]
return [feature for feature in FEATURE_DIMENSION_ORDER if feature in ranges]
def feature_range(
- kind: LibraryGeneratorName,
+ kind: GeneratorName,
feature: FeatureKey,
) -> FeatureRange:
return GENERATOR_FEATURE_RANGES[kind][feature]
-def supports(kind: LibraryGeneratorName, feature: FeatureKey) -> bool:
+def supports(kind: GeneratorName, feature: FeatureKey) -> bool:
return feature in GENERATOR_FEATURE_RANGES[kind]
diff --git a/src/sampletones_core/features/text.py b/src/sampletones_core/features/text.py
new file mode 100644
index 000000000..dcfe05b54
--- /dev/null
+++ b/src/sampletones_core/features/text.py
@@ -0,0 +1,49 @@
+from typing import Final, List, Optional
+
+from sampletones_core.features.envelope import Envelope
+from sampletones_shared.constants.symbols import PIPE
+
+LOOP_POINT: Final[str] = PIPE
+ITEM_SEPARATOR: Final[str] = " "
+
+
+def format_envelope(envelope: Envelope[int]) -> str:
+ """Writes a dimension out as a reader types it: its values, spaced, around the point they repeat from.
+
+ Args:
+ envelope: The dimension to write out.
+
+ Returns:
+ str: The values separated by spaces, with ``|`` standing before the item they repeat from.
+ """
+ tokens: List[str] = [str(item) for item in envelope.items]
+ if envelope.loop_point is not None:
+ tokens.insert(envelope.loop_point, LOOP_POINT)
+
+ return ITEM_SEPARATOR.join(tokens)
+
+
+def parse_envelope(text: str) -> Envelope[int]:
+ """Reads a dimension a reader typed, taking ``|`` as the item the values repeat from.
+
+ Args:
+ text: Whole numbers separated by spaces, with at most one ``|`` standing among them.
+
+ Returns:
+ Envelope[int]: The values written, repeating from the item ``|`` stands before.
+
+ Raises:
+ ValueError: Where a token is neither a whole number nor ``|``, where a second ``|`` stands
+ among the values, or where ``|`` stands past the last value written.
+ """
+ items: List[int] = []
+ loop_point: Optional[int] = None
+ for token in text.split():
+ if token != LOOP_POINT:
+ items.append(int(token))
+ elif loop_point is None:
+ loop_point = len(items)
+ else:
+ raise ValueError(f"a dimension repeats from one item, and {text!r} marks two")
+
+ return Envelope[int](items=tuple(items), loop_point=loop_point)
diff --git a/src/sampletones_core/fft/cqt/frequencies.py b/src/sampletones_core/fft/cqt/frequencies.py
index a66853c96..132cc15c6 100644
--- a/src/sampletones_core/fft/cqt/frequencies.py
+++ b/src/sampletones_core/fft/cqt/frequencies.py
@@ -15,7 +15,7 @@ def calculate_cqt_frequencies(
Calculate center frequencies for CQT bins.
Bins are spaced geometrically from ``cutoff``, so bin ``k`` sits at
- ``cutoff * 2 ** (k / bins_per_octave)`` — a constant ratio between neighbours that keeps the
+ ``cutoff * 2 ** (k / bins_per_octave)`` — a constant ratio between neighbors that keeps the
quality factor constant across the spectrum.
Args:
diff --git a/src/sampletones_core/fft/instantaneous.py b/src/sampletones_core/fft/instantaneous.py
new file mode 100644
index 000000000..f8ce8c703
--- /dev/null
+++ b/src/sampletones_core/fft/instantaneous.py
@@ -0,0 +1,156 @@
+from dataclasses import dataclass
+from typing import Final, Optional
+
+import numpy as np
+
+from sampletones_core.constants.spectrum import BINS_PER_OCTAVE, CQT_CUTOFF_FREQUENCY
+
+from .cqt.frequencies import calculate_cqt_frequencies
+from .cqt.transform import calculate_cqt_frames
+
+HARMONIC_COUNT: Final[int] = 5
+MINIMUM_COLUMN_ENERGY: Final[float] = 1e-20
+
+
+@dataclass(frozen=True)
+class FundamentalReading:
+ """What a frame's partials place its fundamental at, and how much of the frame stands behind it.
+
+ Attributes:
+ frequency: The fundamental in Hz the frame's harmonics agree on.
+ confidence: The share of the frame's energy those harmonics hold, in ``[0, 1]``.
+ """
+
+ frequency: float
+ confidence: float
+
+
+class InstantaneousPitch:
+ """Where a recording's partials actually sit, read frame by frame from the transform's phase.
+
+ A constant-Q column carries a phase as well as a magnitude, and a partial standing between two
+ bin centers still advances that phase at its own rate. Comparing the phase two columns apart
+ against the rate the bin itself would turn at therefore states the partial's frequency far more
+ finely than the bins are spaced — finely enough to place a note within a fraction of a cent,
+ where the bins alone place it within a semitone.
+
+ Reading around a stated reference is what makes the answer usable: the reference names which
+ bins carry the note's harmonics, and each harmonic's estimate is divided back down and weighted
+ by the energy standing behind it. The harmonics are read in order, each one settled against the
+ fundamental the ones below it agreed on, which is what keeps an upper harmonic on the right side
+ of the whole turn its phase states the reading to within.
+ """
+
+ def __init__(
+ self,
+ audio: np.ndarray,
+ sample_rate: int,
+ hop_length: int,
+ *,
+ cutoff: float = CQT_CUTOFF_FREQUENCY,
+ bins_per_octave: int = BINS_PER_OCTAVE,
+ ) -> None:
+ coefficients = calculate_cqt_frames(audio, sample_rate, hop_length, cutoff, None, bins_per_octave)
+ self._magnitudes: np.ndarray = np.abs(coefficients)
+ self._phases: np.ndarray = np.angle(coefficients)
+ self._frequencies: np.ndarray = calculate_cqt_frequencies(
+ coefficients.shape[0],
+ cutoff,
+ bins_per_octave,
+ )
+ self._energies: np.ndarray = np.sum(self._magnitudes**2, axis=0)
+ self._turn_per_column: np.ndarray = 2.0 * np.pi * self._frequencies * hop_length / sample_rate
+ self._resolution: float = sample_rate / (2.0 * np.pi * hop_length)
+ self._ambiguity: float = sample_rate / hop_length
+
+ @property
+ def columns(self) -> int:
+ """How many frames the transform read."""
+ return int(self._magnitudes.shape[1])
+
+ def at(self, frame: int, reference: float) -> Optional[FundamentalReading]:
+ """Where the fundamental sits in one frame, read around a reference it is known to be near.
+
+ Args:
+ frame: The frame to read.
+ reference: The frequency in Hz the note is expected at.
+
+ Returns:
+ Optional[FundamentalReading]: The reading, or ``None`` where the frame lies outside
+ the transform or the reference names no bin the transform covers.
+ """
+ opening, closing = self._pair(frame)
+ if opening is None or closing is None:
+ return None
+
+ weighted = 0.0
+ weight = 0.0
+ running = reference
+ for harmonic in range(1, HARMONIC_COUNT + 1):
+ bin_index = self._bin_for(reference * harmonic)
+ if bin_index is None:
+ continue
+
+ partial = self._partial_frequency(bin_index, opening, closing, running * harmonic)
+ energy = float(self._magnitudes[bin_index, opening]) ** 2
+ weighted += energy * partial / harmonic
+ weight += energy
+ running = weighted / weight
+
+ if weight <= 0.0:
+ return None
+
+ return FundamentalReading(
+ frequency=weighted / weight,
+ confidence=weight / max(float(self._energies[opening]), MINIMUM_COLUMN_ENERGY),
+ )
+
+ def _pair(self, frame: int) -> tuple[Optional[int], Optional[int]]:
+ """The two columns a phase advance is read across, stepping back at the final frame."""
+ if frame < 0 or frame >= self.columns:
+ return None, None
+
+ if frame + 1 < self.columns:
+ return frame, frame + 1
+
+ if frame > 0:
+ return frame - 1, frame
+
+ return None, None
+
+ def _bin_for(self, frequency: float) -> Optional[int]:
+ """The bin whose center stands nearest a frequency, where the transform reaches it."""
+ if frequency < self._frequencies[0] or frequency > self._frequencies[-1]:
+ return None
+
+ return int(np.argmin(np.abs(self._frequencies - frequency)))
+
+ def _partial_frequency(
+ self,
+ bin_index: int,
+ opening: int,
+ closing: int,
+ expected: float,
+ ) -> float:
+ """What the partial in one bin sounds at, from how far its phase turned between two columns.
+
+ A phase states its turn to within a whole turn, so the reading repeats every
+ ``sample_rate / hop`` hertz and the branch to take is the one standing nearest where the
+ partial is expected. That spacing is far wider than any error the reading itself carries,
+ so choosing by the expectation settles the branch without moving the answer inside it —
+ which is what keeps the upper harmonics of a note usable, since a whole turn there spans
+ less than the semitone their bin covers.
+
+ Args:
+ bin_index: The bin the partial stands in.
+ opening: The column the turn is measured from.
+ closing: The column the turn is measured to.
+ expected: The frequency in Hz the partial is expected near.
+
+ Returns:
+ float: The partial's frequency in Hz.
+ """
+ turned = self._phases[bin_index, closing] - self._phases[bin_index, opening]
+ deviation = np.angle(np.exp(1j * (turned - self._turn_per_column[bin_index])))
+ partial = float(self._frequencies[bin_index] + deviation * self._resolution)
+ return partial + self._ambiguity * round((expected - partial) / self._ambiguity)
diff --git a/src/sampletones_core/fft/spectrum/cqt.py b/src/sampletones_core/fft/spectrum/cqt.py
index 798fc038e..e1f129439 100644
--- a/src/sampletones_core/fft/spectrum/cqt.py
+++ b/src/sampletones_core/fft/spectrum/cqt.py
@@ -83,7 +83,7 @@ def calculate_cqt_spectrum_columns(
The signal is advanced by half a hop before transforming, so column ``i``
represents the frame centered on ``(i + 0.5) * hop_length``. This aligns the
- per-frame timing with the FFT path, which analyses a window centered on each frame.
+ per-frame timing with the FFT path, which analyzes a window centered on each frame.
Args:
audio: Input audio as a numpy array.
diff --git a/src/sampletones_core/formats/binary.py b/src/sampletones_core/formats/binary.py
new file mode 100644
index 000000000..a7b0bf7f5
--- /dev/null
+++ b/src/sampletones_core/formats/binary.py
@@ -0,0 +1,122 @@
+import struct
+
+from sampletones_shared.exceptions import TruncatedDataError
+
+
+class BinaryWriter:
+ """Builds a little-endian byte buffer through named, semantic write methods.
+
+ Binary file writing goes through this class, so raw struct packing stays confined here and
+ the writers above it read field by field, the way the format specification states them.
+ """
+
+ def __init__(self) -> None:
+ self._buffer = bytearray()
+
+ @property
+ def data(self) -> bytes:
+ return bytes(self._buffer)
+
+ def __len__(self) -> int:
+ return len(self._buffer)
+
+ def write_bytes(self, data: bytes) -> None:
+ self._buffer.extend(data)
+
+ def write_uint8(self, value: int) -> None:
+ self._buffer.extend(struct.pack(" None:
+ self._buffer.extend(struct.pack(" None:
+ self._buffer.extend(struct.pack(" None:
+ self._buffer.extend(struct.pack(" None:
+ self._buffer.extend(struct.pack(" None:
+ """Writes ``text`` as UTF-8 into a fixed ``length``-byte field, NUL-padded.
+
+ Text whose UTF-8 encoding exceeds ``length`` bytes is cut to fit the field.
+ """
+ encoded = text.encode("utf-8")[:length]
+ self._buffer.extend(encoded)
+ self._buffer.extend(b"\x00" * (length - len(encoded)))
+
+ def write_counted_string(self, text: str) -> None:
+ """Writes a ``uint32`` byte length followed by the UTF-8 bytes of ``text``."""
+ encoded = text.encode("utf-8")
+ self.write_uint32(len(encoded))
+ self._buffer.extend(encoded)
+
+ def write_terminated_string(self, text: str) -> None:
+ """Writes the UTF-8 bytes of ``text`` followed by a single NUL terminator."""
+ self._buffer.extend(text.encode("utf-8"))
+ self._buffer.extend(b"\x00")
+
+
+class BinaryReader:
+ """Takes a little-endian byte buffer apart through named, semantic read methods.
+
+ Binary file reading goes through this class, so raw struct unpacking stays confined here and
+ the readers above it take the fields one by one, the way the format specification states them.
+ Every read advances past what it took, so a reader states the layout as a sequence of calls.
+ """
+
+ def __init__(self, data: bytes) -> None:
+ self._data = data
+ self._offset = 0
+
+ @property
+ def remaining(self) -> int:
+ """The bytes left between where the reader stands and the end of the buffer."""
+ return len(self._data) - self._offset
+
+ def read_bytes(self, count: int) -> bytes:
+ """Takes the next ``count`` bytes.
+
+ Raises:
+ TruncatedDataError: If the buffer holds fewer than ``count`` bytes from here.
+ """
+ self._require(count)
+ chunk = self._data[self._offset : self._offset + count]
+ self._offset += count
+ return chunk
+
+ def read_uint8(self) -> int:
+ return self._read(" int:
+ return self._read(" int:
+ return self._read(" int:
+ return self._read(" str:
+ """Takes a ``uint32`` byte length and the UTF-8 text of that many bytes behind it.
+
+ Raises:
+ TruncatedDataError: If the buffer holds fewer bytes than the length states.
+ UnicodeDecodeError: If those bytes are not UTF-8 text.
+ """
+ return self.read_bytes(self.read_uint32()).decode("utf-8")
+
+ def _read(self, fmt: str) -> int:
+ size = struct.calcsize(fmt)
+ self._require(size)
+ (value,) = struct.unpack_from(fmt, self._data, self._offset)
+ self._offset += size
+ return int(value)
+
+ def _require(self, count: int) -> None:
+ if count > self.remaining:
+ raise TruncatedDataError(
+ f"A read of {count} bytes at offset {self._offset} runs past the {len(self._data)} bytes held"
+ )
diff --git a/src/sampletones_core/formats/bitphase/builder.py b/src/sampletones_core/formats/bitphase/builder.py
index e91dba294..ba8bc48c4 100644
--- a/src/sampletones_core/formats/bitphase/builder.py
+++ b/src/sampletones_core/formats/bitphase/builder.py
@@ -2,9 +2,10 @@
from dataclasses import dataclass
from typing import Dict, List, Optional, Sequence, Tuple
-from sampletones_core.constants.enums import GeneratorName
+from sampletones_core.constants.enums import ChannelName
from sampletones_core.constants.general import SILENT_VOLUME
-from sampletones_core.exporters.slices import iterate_sample_slices
+from sampletones_core.exporters.slices import iterate_voice_slices
+from sampletones_core.exports.request import InstrumentExport, SampleExport
from sampletones_core.formats.bitphase.envelopes import (
ChannelEnvelopes,
features_to_envelopes,
@@ -28,7 +29,7 @@
)
from sampletones_core.formats.bitphase.specification.channels import (
CHANNEL_LABELS,
- GENERATOR_NAME_TO_CHANNEL_INDEX,
+ CHANNEL_TO_INDEX,
ChannelIndex,
)
from sampletones_core.formats.bitphase.specification.chip import (
@@ -61,12 +62,11 @@
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.timing import Groove, Metre, RowRate, calculate_groove
-from sampletones_core.trackers.request import InstrumentExport, SampleExport
+from sampletones_core.project.voices.note_off import NoteOff
+from sampletones_core.project.voices.note_on import NoteOn
+from sampletones_core.timing import Groove, Meter, RowRate, calculate_groove
from sampletones_shared.constants.project import DEFAULT_ROWS_PER_PATTERN, DEFAULT_SPEED
PREVIEW_SPEED = DEFAULT_SPEED
@@ -81,14 +81,14 @@
@dataclass(frozen=True)
-class Voice:
+class SliceVoice:
"""One built instrument together with the table and the note that triggers it.
Attributes:
number: Value a pattern's instrument column carries to play the instrument.
instrument: The 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.
+ channel: The NES channel the slice was reconstructed for.
initial_pitch: Pitch the slice's contour is measured against.
ticks: How many ticks the instrument runs before it loops.
"""
@@ -96,24 +96,24 @@ class Voice:
number: int
instrument: BitphaseInstrument
table: BitphaseTable
- generator: GeneratorName
+ channel: ChannelName
initial_pitch: int
ticks: int
-VoiceTable = Dict[Tuple[str, GeneratorName], Voice]
+SliceVoiceTable = Dict[Tuple[str, ChannelName], SliceVoice]
-def _build_voice(
+def _build_slice_voice(
index: int,
name: str,
- generator: GeneratorName,
+ channel: ChannelName,
initial_pitch: int,
envelopes: ChannelEnvelopes,
*,
maximum_table_id: int,
-) -> Voice:
- """Numbers one generator slice and packages it as an instrument-and-table pair.
+) -> SliceVoice:
+ """Numbers one channel slice and packages it as an instrument-and-table pair.
Instruments and tables are numbered alike, so a pattern cell names the same position
in both columns. The document states how far the table numbering reaches, since a song
@@ -131,7 +131,7 @@ def _build_voice(
if table_id > maximum_table_id:
raise ValueError(f"Document holds room for {maximum_table_id + 1} slice tables")
- return Voice(
+ return SliceVoice(
number=number,
instrument=BitphaseInstrument(
id=format_instrument_id(number),
@@ -145,25 +145,25 @@ def _build_voice(
loop=envelopes.loop,
name=name,
),
- generator=generator,
+ channel=channel,
initial_pitch=initial_pitch,
ticks=len(envelopes.rows),
)
-def _note_cell(channel_generator: GeneratorName, pitch: int) -> NoteCell:
+def _note_cell(channel_generator: ChannelName, pitch: int) -> NoteCell:
"""Resolves a pitch to the note column of the channel the row sits on.
The noise channel reads its note as a period selector, so its pitch takes the
mapping that reproduces that period; every other channel reads the tuning table.
"""
- if channel_generator == GeneratorName.NOISE:
+ if channel_generator == ChannelName.NOISE:
return note_index_to_note_cell(noise_period_to_note_index(pitch))
return note_index_to_note_cell(pitch_to_note_index(pitch))
-def _trigger_row(voice: Voice, note: NoteCell, volume: int) -> BitphaseRow:
+def _trigger_row(voice: SliceVoice, note: NoteCell, volume: int) -> BitphaseRow:
return BitphaseRow(
note=note,
instrument=voice.number,
@@ -210,7 +210,7 @@ def _build_song(
)
-def _preview_length(voices: Sequence[Voice]) -> int:
+def _preview_length(voices: Sequence[SliceVoice]) -> 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(
@@ -219,7 +219,7 @@ def _preview_length(voices: Sequence[Voice]) -> int:
)
-def _preview_order(voices: Sequence[Voice], length: int) -> Tuple[int, ...]:
+def _preview_order(voices: Sequence[SliceVoice], 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
@@ -231,14 +231,14 @@ def _preview_order(voices: Sequence[Voice], length: int) -> Tuple[int, ...]:
def _preview_patterns(
- voices: Sequence[Voice],
+ voices: Sequence[SliceVoice],
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 = CHANNEL_TO_INDEX[voice.channel]
+ note = _note_cell(voice.channel, voice.initial_pitch)
channel_rows[channel][PREVIEW_TRIGGER_ROW] = _trigger_row(
voice,
note,
@@ -257,7 +257,7 @@ def _preview_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
+ Every channel 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.
@@ -271,15 +271,14 @@ def sample_to_bitphase(request: SampleExport) -> BitphaseProject:
ValueError: If the reconstruction holds more slices than Bitphase has room for.
"""
voices = [
- _build_voice(
+ _build_slice_voice(
index,
instrument.name,
- instrument.generator,
+ instrument.channel,
instrument.features.initial_pitch,
features_to_envelopes(
instrument.features,
- instrument.generator,
- loop=instrument.loop,
+ instrument.channel,
),
maximum_table_id=MAX_TABLE_ID,
)
@@ -307,7 +306,7 @@ def sample_to_bitphase(request: SampleExport) -> BitphaseProject:
def instrument_to_bitphase(request: InstrumentExport) -> BitphaseProject:
- """Builds a playable Bitphase document holding one generator slice.
+ """Builds a playable Bitphase document holding one channel slice.
Args:
request: The slice to write.
@@ -319,6 +318,7 @@ def instrument_to_bitphase(request: InstrumentExport) -> BitphaseProject:
name=request.name,
instruments=(request,),
nes_frequency=request.nes_frequency,
+ tuning=request.tuning,
)
return sample_to_bitphase(sample)
@@ -327,37 +327,33 @@ def _build_voice_table(
project: Project,
*,
maximum_table_id: int,
-) -> Tuple[List[Voice], VoiceTable]:
- voices: List[Voice] = []
- by_reference: VoiceTable = {}
+) -> Tuple[List[SliceVoice], SliceVoiceTable]:
+ voices: List[SliceVoice] = []
+ by_reference: SliceVoiceTable = {}
- for sample_slice in iterate_sample_slices(project):
+ for index, voice_slice in enumerate(iterate_voice_slices(project)):
envelopes = features_to_envelopes(
- sample_slice.features,
- sample_slice.generator,
- loop=sample_slice.sample.loop,
+ voice_slice.features,
+ voice_slice.channel,
)
- voice = _build_voice(
- sample_slice.index,
- sample_slice.instrument_name,
- sample_slice.generator,
- sample_slice.features.initial_pitch,
+ voice = _build_slice_voice(
+ index,
+ voice_slice.instrument_name,
+ voice_slice.channel,
+ voice_slice.features.initial_pitch,
envelopes,
maximum_table_id=maximum_table_id,
)
voices.append(voice)
- by_reference[sample_slice.key] = voice
+ by_reference[voice_slice.key] = voice
return voices, by_reference
-def _resolve_voice(reference: Instrument, voices: VoiceTable) -> Voice:
- voice = voices.get((reference.sample_id, reference.generator_name))
+def _resolve_slice_voice(reference: NoteOn, channel: ChannelName, voices: SliceVoiceTable) -> SliceVoice:
+ voice = voices.get((reference.voice_id, channel))
if voice is None:
- raise ValueError(
- f"Row references sample '{reference.sample_id}' slice "
- f"'{reference.generator_name}' that has no instrument"
- )
+ raise ValueError(f"Row references voice '{reference.voice_id}' on channel '{channel}' with no instrument")
return voice
@@ -381,13 +377,13 @@ def _volume_column(volume: Optional[int]) -> int:
def _row_cell(
row: Row,
- channel_generator: GeneratorName,
- voices: VoiceTable,
+ channel_generator: ChannelName,
+ voices: SliceVoiceTable,
) -> 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.
+ ValueError: If the line references a voice that has no instrument on this channel.
"""
volume = _volume_column(row.volume)
cell = BitphaseRow(volume=volume)
@@ -398,8 +394,8 @@ def _row_cell(
note=NoteCell(name=int(NoteName.OFF)),
volume=volume,
)
- case Instrument() as reference:
- voice = _resolve_voice(reference, voices)
+ case NoteOn() as reference:
+ voice = _resolve_slice_voice(reference, channel_generator, voices)
pitch = voice.initial_pitch + (row.transpose or 0)
cell = _trigger_row(
voice,
@@ -415,10 +411,10 @@ def _row_cell(
def _channel_rows(
rows: Sequence[Row],
length: int,
- generator: GeneratorName,
- voices: VoiceTable,
+ channel: ChannelName,
+ voices: SliceVoiceTable,
) -> List[BitphaseRow]:
- cells = [_row_cell(row, generator, voices) for row in rows[:length]]
+ cells = [_row_cell(row, channel, voices) for row in rows[:length]]
cells.extend(BitphaseRow() for _ in range(length - len(cells)))
return cells
@@ -428,13 +424,13 @@ def _project_groove(project: Project) -> Groove:
A Bitphase song holds a speed alone, so the fractional row rate a tempo asks for is
carried by a groove: whole tick counts that vary from row to row and average out to the
- rate, placed by the metre so the longer rows fall on the bar and the beat. The engine's
+ rate, placed by the meter so the longer rows fall on the bar and the beat. The engine's
own speed range bounds them, and the groove's mean states the rate it reached.
"""
settings = project.settings
return calculate_groove(
RowRate.from_settings(settings),
- Metre.from_settings(settings, rows=project.song.rows_per_pattern),
+ Meter.from_settings(settings, rows=project.song.rows_per_pattern),
minimum_ticks=MIN_INITIAL_SPEED,
maximum_ticks=MAX_INITIAL_SPEED,
)
@@ -492,7 +488,7 @@ def _groove_channel_rows(length: int, table_id: int) -> List[BitphaseRow]:
def _document_tables(
- voices: Sequence[Voice],
+ voices: Sequence[SliceVoice],
groove_table: Optional[BitphaseTable],
) -> Tuple[BitphaseTable, ...]:
"""Gathers the tables a document holds: one per slice, and the groove where it takes one."""
@@ -505,7 +501,7 @@ def _document_tables(
def _project_patterns(
project: Project,
- voices: VoiceTable,
+ voices: SliceVoiceTable,
groove_table: Optional[BitphaseTable],
) -> Tuple[BitphasePattern, ...]:
"""Flattens the song's per-channel arrangement into whole-pattern order positions.
@@ -527,20 +523,20 @@ def _project_patterns(
groove_table.id,
)
- for generator in GeneratorName.items():
- index = frame.get(generator)
+ for channel_name in ChannelName.items():
+ index = frame.get(channel_name)
if index is None:
continue
- pattern = song.channels[generator].pattern(index)
+ pattern = song.channels[channel_name].pattern(index)
if pattern is None:
continue
- channel = GENERATOR_NAME_TO_CHANNEL_INDEX[generator]
- channel_rows[channel] = _channel_rows(
+ channel_index = CHANNEL_TO_INDEX[channel_name]
+ channel_rows[channel_index] = _channel_rows(
pattern.rows,
length,
- generator,
+ channel_name,
voices,
)
diff --git a/src/sampletones_core/formats/bitphase/envelopes.py b/src/sampletones_core/formats/bitphase/envelopes.py
index abea08b43..4984383ee 100644
--- a/src/sampletones_core/formats/bitphase/envelopes.py
+++ b/src/sampletones_core/formats/bitphase/envelopes.py
@@ -1,11 +1,9 @@
from dataclasses import dataclass
-from typing import Dict, Final, Optional, Tuple
+from typing import Final, Iterable, Tuple
-import numpy as np
-
-from sampletones_core.constants.enums import FeatureKey, GeneratorName
+from sampletones_core.constants.enums import ChannelName, FeatureKey
from sampletones_core.exporters.feature import Features
-from sampletones_core.exporters.lengths import equalize_lengths
+from sampletones_core.features.envelope import Envelope
from sampletones_core.formats.bitphase.model.instrument import NesInstrumentRow
from sampletones_core.formats.bitphase.notes import noise_arpeggio_to_table_offset
from sampletones_core.formats.bitphase.specification.instruments import (
@@ -26,7 +24,7 @@
@dataclass(frozen=True)
class ChannelEnvelopes:
- """One generator slice expressed the way Bitphase plays it back.
+ """One channel slice expressed the way Bitphase plays it back.
The instrument rows and the table rows advance on their own per-tick counters, so
they share a length and a loop point and stay in step for as long as the note
@@ -43,29 +41,23 @@ class ChannelEnvelopes:
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:
+def _pulse_width(channel: ChannelName, duty_cycle: int) -> int:
"""Reads a duty-cycle item as the field the channel uses it for.
A square channel takes it as the duty itself; the noise channel takes any nonzero
value as its short LFSR mode; the triangle channel plays one fixed waveform.
"""
- match generator:
- case GeneratorName.PULSE1 | GeneratorName.PULSE2:
+ match channel:
+ case ChannelName.PULSE1 | ChannelName.PULSE2:
return duty_cycle
- case GeneratorName.NOISE:
+ case ChannelName.NOISE:
return NOISE_MODE_SHORT if duty_cycle else NOISE_MODE_LONG
- case GeneratorName.TRIANGLE:
+ case ChannelName.TRIANGLE:
return FLAT_PULSE_WIDTH
-def _table_offset(generator: GeneratorName, arpeggio: int) -> int:
- if generator == GeneratorName.NOISE:
+def _table_offset(channel: ChannelName, arpeggio: int) -> int:
+ if channel == ChannelName.NOISE:
return noise_arpeggio_to_table_offset(arpeggio)
return arpeggio
@@ -89,38 +81,33 @@ def _held_volume(frames: int) -> Tuple[int, ...]:
def features_to_envelopes(
features: Features,
- generator: GeneratorName,
- *,
- loop: bool,
+ channel: ChannelName,
) -> ChannelEnvelopes:
- """Converts one generator slice's envelopes into Bitphase instrument and table rows.
+ """Converts one channel 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
slice that leaves its volume to the channel takes a full level for every frame it
- describes, so the channel governs how loud it sounds. 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, resting on the level its volume envelope ends with — silence where the
- slice writes its own, the channel's level where it holds one.
+ describes, so the channel governs how loud it sounds. Bitphase reads every dimension out of
+ one row, so the instrument returns to the earliest row any dimension repeats from; one whose
+ dimensions all halt returns to its last row, resting on the level its volume envelope ends
+ with — silence where the slice writes its own, the channel's level where it holds one.
A slice describing no frame comes back as the one silent row that is the smallest
instrument Bitphase plays.
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.
+ channel: The NES channel the slice was reconstructed for.
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,
+ frames = features.frame_count
+ envelopes = {
+ feature_key: features.envelopes.get(feature_key, Envelope[int]()).resized(frames)
+ for feature_key in (FeatureKey.VOLUME, FeatureKey.ARPEGGIO, FeatureKey.DUTY_CYCLE)
}
- items = equalize_lengths({key: _to_items(array) for key, array in arrays.items()}, loop)
- frames = max(len(values) for values in items.values())
if not frames:
return ChannelEnvelopes(
@@ -129,22 +116,42 @@ def features_to_envelopes(
loop=LOOP_FROM_START,
)
- volumes = items[FeatureKey.VOLUME] or _held_volume(frames)
- arpeggios = items[FeatureKey.ARPEGGIO]
- duty_cycles = items[FeatureKey.DUTY_CYCLE]
+ volumes = envelopes[FeatureKey.VOLUME].items or _held_volume(frames)
+ arpeggios = envelopes[FeatureKey.ARPEGGIO].items
+ duty_cycles = envelopes[FeatureKey.DUTY_CYCLE].items
rows = tuple(
NesInstrumentRow(
- pulse_width=_pulse_width(generator, duty_cycles[frame] if duty_cycles else FLAT_PULSE_WIDTH),
+ pulse_width=_pulse_width(channel, duty_cycles[frame] if duty_cycles else FLAT_PULSE_WIDTH),
volume_or_rate=volume,
)
for frame, volume in enumerate(volumes)
)
contour = arpeggios or (NO_TABLE_OFFSET,) * len(volumes)
- table_rows = tuple(_table_offset(generator, arpeggio) for arpeggio in contour)
+ table_rows = tuple(_table_offset(channel, arpeggio) for arpeggio in contour)
return ChannelEnvelopes(
rows=rows,
table_rows=table_rows,
- loop=LOOP_FROM_START if loop else len(rows) - 1,
+ loop=_loop_row(envelopes.values(), len(rows)),
)
+
+
+def _loop_row(envelopes: Iterable[Envelope[int]], rows: int) -> int:
+ """The row a Bitphase instrument returns to, which is the earliest any dimension repeats from.
+
+ Bitphase reads every dimension out of one row, so one point serves them all and the earliest
+ keeps each dimension sounding what it would have sounded.
+
+ Args:
+ envelopes: The dimensions the instrument writes.
+ rows: How many rows the instrument holds.
+
+ Returns:
+ int: The row to return to, held inside the rows the instrument carries.
+ """
+ points = [envelope.loop_point for envelope in envelopes if envelope.loop_point is not None]
+ if not points:
+ return rows - 1
+
+ return min(*points, rows - 1)
diff --git a/src/sampletones_core/formats/bitphase/model/project.py b/src/sampletones_core/formats/bitphase/model/project.py
index 27f1b72a1..babd977e0 100644
--- a/src/sampletones_core/formats/bitphase/model/project.py
+++ b/src/sampletones_core/formats/bitphase/model/project.py
@@ -46,7 +46,7 @@ class BitphaseProject(BaseModel):
)
pattern_order_colors: Dict[int, str] = Field(
default_factory=dict,
- description="Highlight colour per order position.",
+ description="Highlight color per order position.",
)
instruments: Tuple[BitphaseInstrument, ...] = Field(
...,
diff --git a/src/sampletones_core/formats/bitphase/model/song.py b/src/sampletones_core/formats/bitphase/model/song.py
index 3c852e3f8..bd85a11b9 100644
--- a/src/sampletones_core/formats/bitphase/model/song.py
+++ b/src/sampletones_core/formats/bitphase/model/song.py
@@ -56,7 +56,7 @@ class BitphaseSong(BaseModel):
)
a4_tuning_hz: float = Field(
default=DEFAULT_A4_TUNING,
- description="Concert pitch the tuning table centres on.",
+ description="Concert pitch the tuning table centers on.",
)
virtual_channel_map: Dict[int, int] = Field(
default_factory=dict,
diff --git a/src/sampletones_core/formats/bitphase/preset.py b/src/sampletones_core/formats/bitphase/preset.py
index fbd1a0482..1d2275450 100644
--- a/src/sampletones_core/formats/bitphase/preset.py
+++ b/src/sampletones_core/formats/bitphase/preset.py
@@ -2,7 +2,8 @@
from pathlib import Path
from typing import Final, Sequence, Tuple
-from sampletones_core.constants.enums import GeneratorName
+from sampletones_core.constants.enums import ChannelName
+from sampletones_core.exports.request import InstrumentExport
from sampletones_core.formats.bitphase.envelopes import features_to_envelopes
from sampletones_core.formats.bitphase.model.instrument import BitphaseInstrumentPreset, NesInstrumentRow
from sampletones_core.formats.bitphase.notes import pitch_to_note_index
@@ -14,7 +15,6 @@
)
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,
@@ -24,7 +24,7 @@
def _tone_offsets(
- generator: GeneratorName,
+ channel: ChannelName,
initial_pitch: int,
contour: Sequence[int],
) -> Tuple[int, ...]:
@@ -37,7 +37,7 @@ def _tone_offsets(
than from a period offset, so its rows hold a flat offset and the note carries the
pitch.
"""
- if generator == GeneratorName.NOISE:
+ if channel == ChannelName.NOISE:
return (NO_TONE_OFFSET,) * len(contour)
base_index = pitch_to_note_index(initial_pitch)
@@ -56,18 +56,17 @@ def instrument_to_preset(request: InstrumentExport) -> BitphaseInstrumentPreset:
"""Builds the single-instrument file Bitphase's instruments panel loads.
Args:
- request: The generator slice to write.
+ request: The channel slice to write.
Returns:
BitphaseInstrumentPreset: The instrument to serialize.
"""
envelopes = features_to_envelopes(
request.features,
- request.generator,
- loop=request.loop,
+ request.channel,
)
offsets = _tone_offsets(
- request.generator,
+ request.channel,
request.features.initial_pitch,
envelopes.table_rows,
)
diff --git a/src/sampletones_core/formats/bitphase/specification/channels.py b/src/sampletones_core/formats/bitphase/specification/channels.py
index 838a6f555..25c6d4f3a 100644
--- a/src/sampletones_core/formats/bitphase/specification/channels.py
+++ b/src/sampletones_core/formats/bitphase/specification/channels.py
@@ -1,7 +1,7 @@
from enum import IntEnum
from typing import Dict, Final, Tuple
-from sampletones_core.constants.enums import GeneratorName
+from sampletones_core.constants.enums import ChannelName
class ChannelIndex(IntEnum):
@@ -23,9 +23,9 @@ class ChannelIndex(IntEnum):
)
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,
+CHANNEL_TO_INDEX: Final[Dict[ChannelName, ChannelIndex]] = {
+ ChannelName.PULSE1: ChannelIndex.SQUARE1,
+ ChannelName.PULSE2: ChannelIndex.SQUARE2,
+ ChannelName.TRIANGLE: ChannelIndex.TRIANGLE,
+ ChannelName.NOISE: ChannelIndex.NOISE,
}
diff --git a/src/sampletones_core/formats/bitphase/specification/chip.py b/src/sampletones_core/formats/bitphase/specification/chip.py
index 24dd1152a..7bb7b3e89 100644
--- a/src/sampletones_core/formats/bitphase/specification/chip.py
+++ b/src/sampletones_core/formats/bitphase/specification/chip.py
@@ -1,7 +1,8 @@
from enum import StrEnum
from typing import Dict, Final
-from sampletones_core.constants.general import A4_FREQUENCY, APU_CLOCK
+from sampletones_core.constants.general import APU_CLOCK
+from sampletones_shared.constants.music import A4_FREQUENCY
CHIP_TYPE_NES: Final[str] = "nes"
diff --git a/src/sampletones_core/formats/famitracker/binary.py b/src/sampletones_core/formats/famitracker/binary.py
index ff778b900..2b1984b13 100644
--- a/src/sampletones_core/formats/famitracker/binary.py
+++ b/src/sampletones_core/formats/famitracker/binary.py
@@ -1,67 +1,17 @@
from __future__ import annotations
-import struct
from contextlib import contextmanager
from typing import Iterator
+from sampletones_core.formats.binary import BinaryWriter
from sampletones_core.formats.famitracker.specification.blocks import BLOCK_NAME_LENGTH, Block
-class BinaryWriter:
- """Builds a little-endian byte buffer through named, semantic write methods.
-
- All FamiTracker file writing goes through this class, so raw struct packing
- stays confined here and the higher-level block writers read field-by-field like
- the format specification.
- """
-
- def __init__(self) -> None:
- self._buffer = bytearray()
-
- @property
- def data(self) -> bytes:
- return bytes(self._buffer)
-
- def __len__(self) -> int:
- return len(self._buffer)
-
- def write_bytes(self, data: bytes) -> None:
- self._buffer.extend(data)
-
- def write_uint8(self, value: int) -> None:
- self._buffer.extend(struct.pack(" None:
- self._buffer.extend(struct.pack(" None:
- self._buffer.extend(struct.pack(" None:
- self._buffer.extend(struct.pack(" None:
- """Writes ``text`` as UTF-8 into a fixed ``length``-byte field, NUL-padded.
-
- Text whose UTF-8 encoding exceeds ``length`` bytes is cut to fit the field.
- """
- encoded = text.encode("utf-8")[:length]
- self._buffer.extend(encoded)
- self._buffer.extend(b"\x00" * (length - len(encoded)))
-
- def write_counted_string(self, text: str) -> None:
- """Writes a ``uint32`` byte length followed by the UTF-8 bytes of ``text``."""
- encoded = text.encode("utf-8")
- self.write_uint32(len(encoded))
- self._buffer.extend(encoded)
-
- def write_terminated_string(self, text: str) -> None:
- """Writes the UTF-8 bytes of ``text`` followed by a single NUL terminator."""
- self._buffer.extend(text.encode("utf-8"))
- self._buffer.extend(b"\x00")
+class FamiTrackerWriter(BinaryWriter):
+ """A binary writer that frames the named, versioned blocks a FamiTracker module is built from."""
@contextmanager
- def block(self, descriptor: Block) -> Iterator[BinaryWriter]:
+ def block(self, descriptor: Block) -> Iterator[FamiTrackerWriter]:
"""Frames a named, versioned block around a buffered payload.
The payload written to the yielded writer is emitted with the block name
@@ -69,19 +19,19 @@ def block(self, descriptor: Block) -> Iterator[BinaryWriter]:
payload size (``int32``) in front of it, so the size is known before the
header is written.
"""
- payload = BinaryWriter()
+ payload = FamiTrackerWriter()
yield payload
body = payload.data
self._write_block_name(descriptor.name)
self.write_int32(descriptor.version)
self.write_int32(len(body))
- self._buffer.extend(body)
+ self.write_bytes(body)
def _write_block_name(self, name: str) -> None:
encoded = name.encode("ascii")
if len(encoded) > BLOCK_NAME_LENGTH:
raise ValueError(f"Block name '{name}' exceeds {BLOCK_NAME_LENGTH} bytes")
- self._buffer.extend(encoded)
- self._buffer.extend(b"\x00" * (BLOCK_NAME_LENGTH - len(encoded)))
+ self.write_bytes(encoded)
+ self.write_bytes(b"\x00" * (BLOCK_NAME_LENGTH - len(encoded)))
diff --git a/src/sampletones_core/formats/famitracker/builder.py b/src/sampletones_core/formats/famitracker/builder.py
index 8d797231b..9a1396768 100644
--- a/src/sampletones_core/formats/famitracker/builder.py
+++ b/src/sampletones_core/formats/famitracker/builder.py
@@ -1,11 +1,11 @@
from typing import List, Optional, Tuple
-from sampletones_core.constants.enums import GeneratorName
+from sampletones_core.constants.enums import ChannelName
from sampletones_core.exporters.feature import Features
from sampletones_core.exporters.slices import (
InstrumentSlot,
InstrumentTable,
- iterate_sample_slices,
+ iterate_instrument_entries,
)
from sampletones_core.formats.famitracker.model.instrument import Instrument2A03
from sampletones_core.formats.famitracker.model.module import (
@@ -26,14 +26,13 @@
)
from sampletones_core.formats.famitracker.specification.channels import (
CHANNEL_COUNT_2A03,
- GENERATOR_NAME_TO_CHANNEL_ID,
+ CHANNEL_TO_ID,
ChannelId,
)
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,
DEFAULT_SPEED_SPLIT_POINT,
@@ -53,43 +52,34 @@
MIN_OCTAVE,
NoteValue,
)
-from sampletones_core.project.instruments.instrument import Instrument
-from sampletones_core.project.instruments.note_off import NoteOff
from sampletones_core.project.patterns.channel import Channel
from sampletones_core.project.patterns.row import Row
from sampletones_core.project.project import Project
from sampletones_core.project.song import Song
+from sampletones_core.project.voices.note_off import NoteOff
+from sampletones_core.project.voices.note_on import NoteOn
+from sampletones_shared.application import SAMPLETONES_COPYRIGHT
def build_instrument(
index: int,
name: str,
features: Features,
- *,
- loop: bool,
) -> Instrument2A03:
- """Builds one FamiTracker instrument from the envelopes of a generator slice.
+ """Builds one FamiTracker instrument from a set of envelopes.
- The slice's envelopes become the instrument's five 2A03 sequences, so an instrument reaching a
+ The envelopes become the instrument's five 2A03 sequences, so an instrument reaching a
``.fti`` file on its own and one taking a slot in a module are built the same way.
Args:
index: The slot the instrument is numbered under.
name: The name FamiTracker lists the instrument by.
features: The per-dimension envelopes the sequences are read from.
- loop: Whether every populated sequence loops from its first item, sustaining a held note.
Returns:
The instrument the envelopes describe.
"""
- 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=loop,
- )
+ sequences = features_to_instrument_sequences(features)
return Instrument2A03(
index=index,
@@ -99,39 +89,42 @@ def build_instrument(
def build_instrument_table(project: Project) -> Tuple[List[Instrument2A03], InstrumentTable]:
- """Builds one FamiTracker instrument per generator slice of every sample.
+ """Builds the module's instruments and the table a pattern row resolves through.
+
+ A sample contributes one instrument for every channel its reconstruction covers, so it yields
+ one to four; a hand-written voice contributes one instrument every channel it sounds on
+ reaches, each against that channel's own root. Instruments are numbered in voice order, then channel order.
- Each sample contributes one instrument for every channel its reconstruction
- covers, so a sample yields one to four instruments. Instruments are numbered in
- sample order, then channel order.
+ Raises:
+ ValueError: If the project holds more instruments than FamiTracker has room for.
"""
instruments: List[Instrument2A03] = []
slots: InstrumentTable = {}
- for sample_slice in iterate_sample_slices(project):
- if sample_slice.index >= MAX_INSTRUMENTS:
+ for entry in iterate_instrument_entries(project):
+ if entry.index >= MAX_INSTRUMENTS:
raise ValueError(f"Module exceeds the FamiTracker limit of {MAX_INSTRUMENTS} instruments")
instruments.append(
build_instrument(
- sample_slice.index,
- sample_slice.instrument_name,
- sample_slice.features,
- loop=sample_slice.sample.loop,
+ entry.index,
+ entry.name,
+ entry.features,
)
)
- slots[sample_slice.key] = sample_slice.slot
+ for channel, slot in entry.slots.items():
+ slots[(entry.voice_id, channel)] = slot
return instruments, slots
def _note_and_octave(
transpose: int,
- channel_generator: GeneratorName,
+ channel_generator: ChannelName,
slot: InstrumentSlot,
) -> Tuple[int, int]:
base_pitch = slot.initial_pitch + transpose
- if channel_generator == GeneratorName.NOISE:
+ if channel_generator == ChannelName.NOISE:
cell = period_to_note_cell(base_pitch)
else:
cell = pitch_to_note_cell(base_pitch)
@@ -142,7 +135,7 @@ def _note_and_octave(
def _row_cell(
row: Row,
row_number: int,
- channel_generator: GeneratorName,
+ channel_generator: ChannelName,
slots: InstrumentTable,
) -> Optional[RowCell]:
note = EMPTY_NOTE
@@ -153,12 +146,12 @@ def _row_cell(
match row.command:
case NoteOff():
note = int(NoteValue.HALT)
- case Instrument() as reference:
- slot = slots.get((reference.sample_id, reference.generator_name))
+ case NoteOn() as reference:
+ slot = slots.get((reference.voice_id, channel_generator))
if slot is None:
raise ValueError(
- f"Row references sample '{reference.sample_id}' slice "
- f"'{reference.generator_name}' that has no instrument"
+ f"Row references voice '{reference.voice_id}' on channel "
+ f"'{channel_generator}' with no instrument"
)
instrument = slot.index
note, octave = _note_and_octave(
@@ -191,11 +184,11 @@ def _has_data(cell: RowCell) -> bool:
def _channel_patterns(
- generator: GeneratorName,
+ name: ChannelName,
channel: Channel,
slots: InstrumentTable,
) -> List[PatternData]:
- channel_id = GENERATOR_NAME_TO_CHANNEL_ID[generator]
+ channel_id = CHANNEL_TO_ID[name]
patterns: List[PatternData] = []
for index in sorted(channel.patterns):
@@ -209,7 +202,7 @@ def _channel_patterns(
_row_cell(
row,
row_number,
- generator,
+ name,
slots,
)
for row_number, row in enumerate(pattern.rows)
@@ -239,17 +232,17 @@ def _build_order(song: Song) -> Tuple[OrderFrame, ...]:
if len(song.order) > MAX_FRAMES:
raise ValueError(f"Order length {len(song.order)} exceeds the FamiTracker limit of {MAX_FRAMES} frames")
- empty_indices = {generator: _reserved_empty_index(song.channels[generator]) for generator in GeneratorName.items()}
- for generator, empty_index in empty_indices.items():
+ empty_indices = {channel: _reserved_empty_index(song.channels[channel]) for channel in ChannelName.items()}
+ for channel, empty_index in empty_indices.items():
if empty_index > MAX_PATTERN_INDEX:
- raise ValueError(f"Channel '{generator}' has no free pattern index for empty order slots")
+ raise ValueError(f"Channel '{channel}' has no free pattern index for empty order slots")
frames: List[OrderFrame] = []
for frame in song.order:
entries: List[int] = []
- for generator in GeneratorName.items():
- index = frame.get(generator)
- entries.append(index if index is not None else empty_indices[generator])
+ for channel in ChannelName.items():
+ index = frame.get(channel)
+ entries.append(index if index is not None else empty_indices[channel])
entries.append(DPCM_EMPTY_PATTERN_INDEX)
frames.append(tuple(entries))
@@ -278,15 +271,15 @@ def project_to_module(project: Project) -> FamiTrackerModule:
information = ModuleInformation(
title=info.title,
author=info.author,
- copyright=DEFAULT_COPYRIGHT,
+ copyright=SAMPLETONES_COPYRIGHT,
)
patterns: List[PatternData] = []
- for generator in GeneratorName.items():
+ for channel in ChannelName.items():
patterns.extend(
_channel_patterns(
- generator,
- song.channels[generator],
+ channel,
+ song.channels[channel],
slots,
),
)
diff --git a/src/sampletones_core/formats/famitracker/footprint.py b/src/sampletones_core/formats/famitracker/footprint.py
index 481ad4941..4fbe3a6a1 100644
--- a/src/sampletones_core/formats/famitracker/footprint.py
+++ b/src/sampletones_core/formats/famitracker/footprint.py
@@ -1,7 +1,7 @@
from dataclasses import dataclass
from typing import Dict, Iterable
-from sampletones_core.constants.enums import GeneratorName
+from sampletones_core.constants.enums import ChannelName
from sampletones_core.exporters.feature import Features
from sampletones_core.formats.famitracker.model.instrument import Instrument2A03
from sampletones_core.formats.famitracker.model.sequence import InstrumentSequence
@@ -66,40 +66,24 @@ def instrument_footprint(instrument: Instrument2A03) -> InstrumentFootprint:
return sequences_footprint(instrument.sequences.values())
-def features_footprint(
- features: Features,
- *,
- loop: bool,
-) -> InstrumentFootprint:
- """Measures the instrument a generator slice's envelopes export to.
+def features_footprint(features: Features) -> InstrumentFootprint:
+ """Measures the instrument a channel slice's envelopes export to.
The envelopes pass through the same builder an export uses, so the measured item counts are
- the ones a file carries: brought to one shared length and capped at what a FamiTracker
+ the ones a file carries: each at the length it was written, capped at what a FamiTracker
sequence holds.
Args:
features: The per-dimension envelopes describing the slice.
- loop: Whether the instrument loops while its note is held, which decides the shared length.
Returns:
InstrumentFootprint: The footprint of the instrument those envelopes describe.
"""
- 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=loop,
- )
+ sequences = features_to_instrument_sequences(features)
return sequences_footprint(sequences.values())
-def reconstruction_footprints(
- reconstruction: Reconstruction,
- *,
- loop: bool,
-) -> Dict[GeneratorName, InstrumentFootprint]:
+def reconstruction_footprints(reconstruction: Reconstruction) -> Dict[ChannelName, InstrumentFootprint]:
"""Measures one instrument per channel a reconstruction plays.
An export writes an instrument for each channel that plays, so the result holds an entry
@@ -108,14 +92,13 @@ def reconstruction_footprints(
Args:
reconstruction: The reconstruction whose channels are measured.
- loop: Whether the sample carrying it loops while its note is held.
Returns:
- Dict[GeneratorName, InstrumentFootprint]: The footprint of each playing channel's instrument.
+ Dict[ChannelName, InstrumentFootprint]: The footprint of each playing channel's instrument.
"""
return {
- generator_name: features_footprint(features, loop=loop)
- for generator_name, features in reconstruction.export().items()
+ channel_name: features_footprint(features)
+ for channel_name, features in reconstruction.export().items()
if features.has_frames
}
diff --git a/src/sampletones_core/formats/famitracker/instrument.py b/src/sampletones_core/formats/famitracker/instrument.py
index 4592e8ad4..9880ceac4 100644
--- a/src/sampletones_core/formats/famitracker/instrument.py
+++ b/src/sampletones_core/formats/famitracker/instrument.py
@@ -1,11 +1,20 @@
-from sampletones_core.formats.famitracker.binary import BinaryWriter
+from typing import Dict
+
+from pydantic import ValidationError
+
+from sampletones_core.formats.binary import BinaryReader
+from sampletones_core.formats.famitracker.binary import FamiTrackerWriter
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.file import (
+ FTI_MAGIC,
+ FTI_VERSION,
+)
from sampletones_core.formats.famitracker.specification.instruments import (
EMPTY_DPCM_ASSIGNMENTS,
EMPTY_DPCM_SAMPLES,
INSTRUMENT_TYPE_2A03,
+ STANDALONE_INSTRUMENT_INDEX,
)
from sampletones_core.formats.famitracker.specification.sequences import (
SEQUENCE_COUNT_2A03,
@@ -13,17 +22,25 @@
SEQUENCE_ENABLED,
SequenceKind,
)
+from sampletones_shared.exceptions import (
+ IncompatibleInstrumentVersionError,
+ InvalidInstrumentValuesError,
+ MalformedInstrumentError,
+ NotAnInstrumentFileError,
+ TruncatedDataError,
+ UnsupportedInstrumentTypeError,
+)
from sampletones_shared.types.path import Pathlike
-from sampletones_shared.utils.serialization import save_binary
+from sampletones_shared.utils.serialization import load_binary, save_binary
-def _write_header(writer: BinaryWriter) -> None:
+def _write_header(writer: FamiTrackerWriter) -> None:
writer.write_bytes(FTI_MAGIC)
writer.write_bytes(FTI_VERSION)
def _write_type_and_name(
- writer: BinaryWriter,
+ writer: FamiTrackerWriter,
instrument: Instrument2A03,
) -> None:
writer.write_uint8(INSTRUMENT_TYPE_2A03)
@@ -31,7 +48,7 @@ def _write_type_and_name(
def _write_sequence(
- writer: BinaryWriter,
+ writer: FamiTrackerWriter,
sequence: InstrumentSequence,
) -> None:
if not sequence.enabled:
@@ -47,20 +64,20 @@ def _write_sequence(
writer.write_int8(item)
-def _write_sequences(writer: BinaryWriter, instrument: Instrument2A03) -> None:
+def _write_sequences(writer: FamiTrackerWriter, instrument: Instrument2A03) -> None:
writer.write_int8(SEQUENCE_COUNT_2A03)
for kind in SequenceKind:
_write_sequence(writer, instrument.sequences[kind])
-def _write_empty_dpcm_section(writer: BinaryWriter) -> None:
+def _write_empty_dpcm_section(writer: FamiTrackerWriter) -> None:
writer.write_uint32(EMPTY_DPCM_ASSIGNMENTS)
writer.write_uint32(EMPTY_DPCM_SAMPLES)
def instrument_to_fti_bytes(instrument: Instrument2A03) -> bytes:
"""Serializes a 2A03 instrument to the FamiTracker ``.fti`` byte layout."""
- writer = BinaryWriter()
+ writer = FamiTrackerWriter()
_write_header(writer)
_write_type_and_name(writer, instrument)
_write_sequences(writer, instrument)
@@ -71,3 +88,125 @@ def instrument_to_fti_bytes(instrument: Instrument2A03) -> bytes:
def write_fti(filepath: Pathlike, instrument: Instrument2A03) -> None:
"""Writes a 2A03 instrument to a ``.fti`` file."""
save_binary(filepath, instrument_to_fti_bytes(instrument))
+
+
+def _read_header(reader: BinaryReader) -> None:
+ magic = reader.read_bytes(len(FTI_MAGIC))
+ if magic != FTI_MAGIC:
+ raise NotAnInstrumentFileError(f"An instrument file opens with {FTI_MAGIC!r}, this one with {magic!r}")
+
+ version = reader.read_bytes(len(FTI_VERSION))
+ if version != FTI_VERSION:
+ raise IncompatibleInstrumentVersionError(
+ f"Instrument file version mismatch: expected {FTI_VERSION!r}, got {version!r}.",
+ expected_version=FTI_VERSION.decode("ascii"),
+ actual_version=version.decode("ascii", errors="replace"),
+ )
+
+
+def _read_type_and_name(reader: BinaryReader) -> str:
+ instrument_type = reader.read_uint8()
+ if instrument_type != INSTRUMENT_TYPE_2A03:
+ raise UnsupportedInstrumentTypeError(
+ f"Instrument type {instrument_type} is read here only as the 2A03 type {INSTRUMENT_TYPE_2A03}"
+ )
+
+ return reader.read_counted_string()
+
+
+def _read_sequence(
+ reader: BinaryReader,
+ kind: SequenceKind,
+) -> InstrumentSequence:
+ if reader.read_int8() == SEQUENCE_DISABLED:
+ return InstrumentSequence(kind=kind)
+
+ length = reader.read_uint32()
+ loop_point = reader.read_int32()
+ release_point = reader.read_int32()
+ setting = reader.read_uint32()
+
+ return InstrumentSequence(
+ kind=kind,
+ items=tuple(reader.read_int8() for _ in range(length)),
+ loop_point=loop_point,
+ release_point=release_point,
+ setting=setting,
+ )
+
+
+def _read_sequences(
+ reader: BinaryReader,
+) -> Dict[SequenceKind, InstrumentSequence]:
+ count = reader.read_int8()
+ if count != SEQUENCE_COUNT_2A03:
+ raise MalformedInstrumentError(
+ f"A 2A03 instrument carries {SEQUENCE_COUNT_2A03} sequences, this one states {count}"
+ )
+
+ sequences: Dict[SequenceKind, InstrumentSequence] = {}
+ for kind in SequenceKind:
+ sequences[kind] = _read_sequence(reader, kind)
+
+ return sequences
+
+
+def _read_dpcm_section(reader: BinaryReader) -> None:
+ """Steps past the key assignments and samples a file states behind its sequences."""
+ reader.read_uint32()
+ reader.read_uint32()
+
+
+def _read_instrument(reader: BinaryReader) -> Instrument2A03:
+ _read_header(reader)
+ name = _read_type_and_name(reader)
+ sequences = _read_sequences(reader)
+ _read_dpcm_section(reader)
+
+ return Instrument2A03(
+ index=STANDALONE_INSTRUMENT_INDEX,
+ name=name,
+ sequences=sequences,
+ )
+
+
+def fti_bytes_to_instrument(data: bytes) -> Instrument2A03:
+ """Reads a 2A03 instrument from the FamiTracker ``.fti`` byte layout.
+
+ The five sequences and the name are what a file carries into an instrument, taken in the
+ order :func:`instrument_to_fti_bytes` writes them. A file states its own DPCM key
+ assignments and samples behind them, which a 2A03 instrument here leaves to the file.
+
+ Args:
+ data: The bytes of a ``.fti`` file.
+
+ Returns:
+ Instrument2A03: The instrument the bytes describe, numbered as a standalone file's.
+
+ Raises:
+ NotAnInstrumentFileError: If the data opens with something other than the signature.
+ IncompatibleInstrumentVersionError: If the file states another layout version.
+ UnsupportedInstrumentTypeError: If the file states a chip other than the 2A03.
+ MalformedInstrumentError: If the file departs from the layout its own fields describe.
+ InvalidInstrumentValuesError: If a sequence carries more items than one holds.
+ """
+ try:
+ return _read_instrument(BinaryReader(data))
+ except (TruncatedDataError, UnicodeDecodeError) as exception:
+ raise MalformedInstrumentError(
+ f"The instrument file ends inside the layout it states: {exception}"
+ ) from exception
+ except ValidationError as exception:
+ raise InvalidInstrumentValuesError(
+ f"Failed to read an instrument due to validation error: {exception}",
+ exception,
+ ) from exception
+
+
+def read_fti(filepath: Pathlike) -> Instrument2A03:
+ """Reads a 2A03 instrument from a ``.fti`` file.
+
+ Raises:
+ FileNotFoundError: If no file stands at ``filepath``.
+ """
+ return fti_bytes_to_instrument(load_binary(filepath))
diff --git a/src/sampletones_core/formats/famitracker/module.py b/src/sampletones_core/formats/famitracker/module.py
index fc88d49ac..d27592e7b 100644
--- a/src/sampletones_core/formats/famitracker/module.py
+++ b/src/sampletones_core/formats/famitracker/module.py
@@ -1,6 +1,6 @@
from typing import Sequence
-from sampletones_core.formats.famitracker.binary import BinaryWriter
+from sampletones_core.formats.famitracker.binary import FamiTrackerWriter
from sampletones_core.formats.famitracker.model.instrument import Instrument2A03
from sampletones_core.formats.famitracker.model.module import (
FamiTrackerModule,
@@ -51,12 +51,12 @@
)
-def _write_file_header(writer: BinaryWriter) -> None:
+def _write_file_header(writer: FamiTrackerWriter) -> None:
writer.write_bytes(FTM_MAGIC)
writer.write_uint32(FTM_VERSION)
-def _write_params_block(writer: BinaryWriter, parameters: ModuleParameters) -> None:
+def _write_params_block(writer: FamiTrackerWriter, parameters: ModuleParameters) -> None:
with writer.block(BLOCK_PARAMS) as body:
body.write_uint8(parameters.expansion_chip)
body.write_int32(parameters.channel_count)
@@ -68,14 +68,14 @@ def _write_params_block(writer: BinaryWriter, parameters: ModuleParameters) -> N
body.write_int32(parameters.speed_split_point)
-def _write_info_block(writer: BinaryWriter, information: ModuleInformation) -> None:
+def _write_info_block(writer: FamiTrackerWriter, information: ModuleInformation) -> None:
with writer.block(BLOCK_INFO) as body:
body.write_fixed_string(information.title, INFO_STRING_LENGTH)
body.write_fixed_string(information.author, INFO_STRING_LENGTH)
body.write_fixed_string(information.copyright, INFO_STRING_LENGTH)
-def _write_header_block(writer: BinaryWriter, track: Track) -> None:
+def _write_header_block(writer: FamiTrackerWriter, track: Track) -> None:
with writer.block(BLOCK_HEADER) as body:
body.write_uint8(SINGLE_TRACK_COUNT - 1)
body.write_terminated_string(track.title)
@@ -85,7 +85,7 @@ def _write_header_block(writer: BinaryWriter, track: Track) -> None:
def _write_instrument_body(
- writer: BinaryWriter,
+ writer: FamiTrackerWriter,
instrument: Instrument2A03,
references: SequenceReferences,
) -> None:
@@ -99,7 +99,7 @@ def _write_instrument_body(
def _write_instruments_block(
- writer: BinaryWriter,
+ writer: FamiTrackerWriter,
instruments: Sequence[Instrument2A03],
references: SequenceReferences,
) -> None:
@@ -112,7 +112,7 @@ def _write_instruments_block(
body.write_counted_string(instrument.name)
-def _write_sequences_block(writer: BinaryWriter, pool: Sequence[PooledSequence]) -> None:
+def _write_sequences_block(writer: FamiTrackerWriter, pool: Sequence[PooledSequence]) -> None:
with writer.block(BLOCK_SEQUENCES) as body:
body.write_int32(len(pool))
for pooled in pool:
@@ -127,7 +127,7 @@ def _write_sequences_block(writer: BinaryWriter, pool: Sequence[PooledSequence])
body.write_int32(pooled.sequence.setting)
-def _write_frames_block(writer: BinaryWriter, track: Track) -> None:
+def _write_frames_block(writer: FamiTrackerWriter, track: Track) -> None:
with writer.block(BLOCK_FRAMES) as body:
body.write_int32(len(track.order))
body.write_int32(track.speed)
@@ -138,7 +138,7 @@ def _write_frames_block(writer: BinaryWriter, track: Track) -> None:
body.write_uint8(pattern_index)
-def _write_patterns_block(writer: BinaryWriter, patterns: Sequence[PatternData]) -> None:
+def _write_patterns_block(writer: FamiTrackerWriter, patterns: Sequence[PatternData]) -> None:
with writer.block(BLOCK_PATTERNS) as body:
for pattern in patterns:
body.write_int32(FIRST_TRACK_INDEX)
@@ -156,12 +156,12 @@ def _write_patterns_block(writer: BinaryWriter, patterns: Sequence[PatternData])
body.write_int8(param)
-def _write_dpcm_samples_block(writer: BinaryWriter) -> None:
+def _write_dpcm_samples_block(writer: FamiTrackerWriter) -> None:
with writer.block(BLOCK_DPCM_SAMPLES) as body:
body.write_uint8(EMPTY_DPCM_SAMPLES)
-def _write_comments_block(writer: BinaryWriter, comment: str) -> None:
+def _write_comments_block(writer: FamiTrackerWriter, comment: str) -> None:
with writer.block(BLOCK_COMMENTS) as body:
body.write_int32(COMMENT_HIDDEN_ON_OPEN)
body.write_terminated_string(comment)
@@ -171,7 +171,7 @@ def module_to_ftm_bytes(module: FamiTrackerModule) -> bytes:
"""Serializes a FamiTracker module to the ``.ftm`` byte layout."""
pool, references = build_sequence_pool(module.instruments)
- writer = BinaryWriter()
+ writer = FamiTrackerWriter()
_write_file_header(writer)
_write_params_block(writer, module.parameters)
_write_info_block(writer, module.information)
diff --git a/src/sampletones_core/formats/famitracker/sequences/features.py b/src/sampletones_core/formats/famitracker/sequences/features.py
index 24e2057f8..b3711e135 100644
--- a/src/sampletones_core/formats/famitracker/sequences/features.py
+++ b/src/sampletones_core/formats/famitracker/sequences/features.py
@@ -1,77 +1,181 @@
-from typing import Dict, Optional, Tuple
+from typing import Dict, Final, Optional
-import numpy as np
-
-from sampletones_core.exporters.lengths import equalize_lengths, limit_lengths
+from sampletones_core.constants.enums import FeatureKey
+from sampletones_core.exporters.feature import Features
+from sampletones_core.exporters.truncation import EnvelopeTruncation
+from sampletones_core.features.envelope import Envelope, releases
from sampletones_core.formats.famitracker.model.sequence import InstrumentSequence
from sampletones_core.formats.famitracker.specification.sequences import (
+ BEND_SEQUENCE_KINDS,
+ FEATURE_KEY_TO_SEQUENCE_KIND,
LOOP_FROM_START,
MAX_SEQUENCE_ITEMS,
NO_LOOP_POINT,
SequenceKind,
)
+ONE_INSTRUMENT: Final[int] = 1
+NO_ARPEGGIO_STEP: Final[int] = 0
+
+
+def features_to_instrument_sequences(features: Features) -> Dict[SequenceKind, InstrumentSequence]:
+ """Builds the five 2A03 sequences from a channel slice's envelopes.
+
+ Each dimension becomes an :class:`InstrumentSequence`; one the generator lacks, or one left
+ to the channel, becomes a disabled sequence the instrument stores nothing for. Every dimension
+ keeps the length it was written at and the item it repeats from, which is how FamiTracker
+ advances each sequence on a counter of its own, and each stands within the items the file
+ holds — see :func:`stored_envelope`.
+
+ A bend travels with an arpeggio that runs beside it, which is what makes the bend an offset
+ from the note — see :func:`_pinning_arpeggio`.
+
+ Args:
+ features: The per-dimension envelopes describing the slice.
+
+ Returns:
+ Dict[SequenceKind, InstrumentSequence]: The sequences, one per dimension FamiTracker holds.
+ """
+ stored = _pinned(_stored_envelopes(features))
+ return {kind: _sequence(kind, stored.get(kind, Envelope[int]())) for kind in SequenceKind}
+
+
+def stored_envelope(
+ feature_key: FeatureKey,
+ envelope: Envelope[int],
+) -> Envelope[int]:
+ """One dimension as a FamiTracker file holds it, within the items a sequence stores.
+
+ The item limit belongs to the file: an envelope carries whatever length it was written at, and
+ this is where a longer one meets what the format stores. A dimension over the limit keeps its
+ opening items, and a volume dimension ending at silence keeps that silence as its last item —
+ the release is what ends a note, so it is the one item worth a place of its own.
+
+ Args:
+ feature_key: The dimension being written.
+ envelope: The dimension as the instrument carries it.
+
+ Returns:
+ Envelope[int]: The dimension within the items the file holds.
+ """
+ if feature_key is FeatureKey.VOLUME and releases(envelope):
+ return _keeping_release(envelope, MAX_SEQUENCE_ITEMS)
+
+ return envelope.limited(MAX_SEQUENCE_ITEMS)
+
+
+def is_shortened(feature_key: FeatureKey, envelope: Envelope[int]) -> bool:
+ """Whether a FamiTracker file leaves items out of this dimension.
+
+ A reader watching an envelope grow and an export reporting what it wrote read the same answer,
+ so the length a file holds is decided in one place.
+
+ Args:
+ feature_key: The dimension being written.
+ envelope: The dimension as the instrument carries it.
+
+ Returns:
+ bool: Whether the file holds fewer items than the dimension carries.
+ """
+ return len(stored_envelope(feature_key, envelope).items) < len(envelope.items)
+
+
+def features_truncation(features: Features) -> Optional[EnvelopeTruncation]:
+ """What a FamiTracker file leaves out of one instrument's envelopes.
+
+ Args:
+ features: The per-dimension envelopes describing the instrument.
+
+ Returns:
+ Optional[EnvelopeTruncation]: The shortening the file imposes, and ``None`` where every
+ dimension is held whole.
+ """
+ source_frames = features.frame_count
+ stored = max(
+ (len(envelope.items) for envelope in _stored_envelopes(features).values()),
+ default=0,
+ )
+ if stored >= source_frames:
+ return None
+
+ return EnvelopeTruncation(
+ frames=stored,
+ source_frames=source_frames,
+ instruments=ONE_INSTRUMENT,
+ )
+
-def _to_items(array: Optional[np.ndarray]) -> Tuple[int, ...]:
- if array is None:
- return ()
- return tuple(int(value) for value in array)
+def _pinned(stored: Dict[SequenceKind, Envelope[int]]) -> Dict[SequenceKind, Envelope[int]]:
+ """These sequences with an arpeggio that runs for as long as the bend beside it does.
+ FamiTracker walks an instrument's sequences in slot order, and an arpeggio in absolute mode
+ reloads the period from the note before the bend sequences add to it. So while the arpeggio
+ runs, a bend item is the offset from the note that this project writes it as; once the
+ arpeggio halts, the same items start accumulating on the running period instead. Writing an
+ arpeggio that covers the bend is what holds the two readings together.
-def _sequence_items(
- arrays: Dict[SequenceKind, Optional[np.ndarray]],
- loop: bool,
-) -> Dict[SequenceKind, Tuple[int, ...]]:
- """Reads the dimensions as the item tuples an instrument stores.
+ Args:
+ stored: The sequences as the file holds them.
- A looping instrument brings every populated dimension to one length, so its envelopes
- repeat in step cycle after cycle. A one-shot carries each dimension at the length it
- was written: a FamiTracker sequence that runs out halts and leaves its final value
- applied for as long as the note sounds, so the shorter dimensions govern the whole
- instrument on their own.
+ Returns:
+ Dict[SequenceKind, Envelope[int]]: Those sequences, the arpeggio reaching the bend's length.
"""
- items_by_kind = {kind: _to_items(array) for kind, array in arrays.items()}
- if loop:
- return equalize_lengths(items_by_kind, loop, limit=MAX_SEQUENCE_ITEMS)
-
- return limit_lengths(items_by_kind, limit=MAX_SEQUENCE_ITEMS)
-
-
-def features_to_instrument_sequences(
- *,
- volume: np.ndarray,
- arpeggio: np.ndarray,
- pitch: Optional[np.ndarray],
- hi_pitch: Optional[np.ndarray],
- duty_cycle: Optional[np.ndarray],
- loop: bool,
-) -> Dict[SequenceKind, InstrumentSequence]:
- """Builds the five 2A03 sequences from per-dimension envelope arrays.
-
- Each dimension becomes an :class:`InstrumentSequence`; a dimension passed as ``None``
- or as an empty envelope becomes a disabled sequence the instrument stores nothing for.
- Item counts stay within the ``MAX_SEQUENCE_ITEMS`` items FamiTracker holds, so a longer
- reconstruction exports its opening frames and the shortening is logged. When ``loop``
- is set, every populated sequence loops from its first item so the instrument sustains
- on a held note, and the populated dimensions share one length to repeat in step.
+ bend_length = max((len(stored.get(kind, Envelope[int]()).items) for kind in BEND_SEQUENCE_KINDS), default=0)
+ if not bend_length:
+ return stored
+
+ return {**stored, SequenceKind.ARPEGGIO: _pinning_arpeggio(stored, bend_length)}
+
+
+def _pinning_arpeggio(stored: Dict[SequenceKind, Envelope[int]], bend_length: int) -> Envelope[int]:
+ """The arpeggio that reloads the note for every tick a bend states an offset for.
+
+ An arpeggio circling from a point runs for as long as the note sounds and needs nothing; one
+ playing its items once holds its final note over the remaining ticks, which is the note it
+ would rest on anyway; and an instrument writing no arpeggio at all takes one item at its own
+ note, repeating.
+
+ Args:
+ stored: The sequences as the file holds them.
+ bend_length: The ticks the longest bend dimension states.
+
+ Returns:
+ Envelope[int]: The arpeggio to write.
"""
- arrays: Dict[SequenceKind, Optional[np.ndarray]] = {
- SequenceKind.VOLUME: volume,
- SequenceKind.ARPEGGIO: arpeggio,
- SequenceKind.PITCH: pitch,
- SequenceKind.HI_PITCH: hi_pitch,
- SequenceKind.DUTY: duty_cycle,
+ arpeggio = stored.get(SequenceKind.ARPEGGIO, Envelope[int]())
+ if arpeggio.loops:
+ return arpeggio
+
+ if not arpeggio.items:
+ return Envelope[int](items=(NO_ARPEGGIO_STEP,), loop_point=LOOP_FROM_START)
+
+ return arpeggio.resized(max(len(arpeggio.items), bend_length))
+
+
+def _stored_envelopes(features: Features) -> Dict[SequenceKind, Envelope[int]]:
+ """Each dimension the slice offers, as the file holds it."""
+ return {
+ FEATURE_KEY_TO_SEQUENCE_KIND[feature_key]: stored_envelope(feature_key, envelope)
+ for feature_key, envelope in features.envelopes.items()
}
- items_by_kind = _sequence_items(arrays, loop)
- sequences: Dict[SequenceKind, InstrumentSequence] = {}
- for kind, items in items_by_kind.items():
- loop_point = LOOP_FROM_START if loop and items else NO_LOOP_POINT
- sequences[kind] = InstrumentSequence(
- kind=kind,
- items=items,
- loop_point=loop_point,
- )
+def _keeping_release(envelope: Envelope[int], limit: int) -> Envelope[int]:
+ """This dimension within ``limit`` items, the last of them the release it ends on."""
+ if len(envelope.items) <= limit:
+ return envelope
+
+ opening = envelope.limited(limit - 1)
+ return opening.with_items(opening.items + envelope.items[-1:])
+
- return sequences
+def _sequence(
+ kind: SequenceKind,
+ envelope: Envelope[int],
+) -> InstrumentSequence:
+ """One sequence as the file states it, with the item the dimension repeats from."""
+ return InstrumentSequence(
+ kind=kind,
+ items=envelope.items,
+ loop_point=envelope.loop_point if envelope.loop_point is not None else NO_LOOP_POINT,
+ )
diff --git a/src/sampletones_core/formats/famitracker/specification/channels.py b/src/sampletones_core/formats/famitracker/specification/channels.py
index 867d032e3..59cea03e2 100644
--- a/src/sampletones_core/formats/famitracker/specification/channels.py
+++ b/src/sampletones_core/formats/famitracker/specification/channels.py
@@ -1,7 +1,7 @@
from enum import IntEnum
from typing import Dict, Final
-from sampletones_core.constants.enums import GeneratorName
+from sampletones_core.constants.enums import ChannelName
class ChannelId(IntEnum):
@@ -16,9 +16,9 @@ class ChannelId(IntEnum):
CHANNEL_COUNT_2A03: Final[int] = 5
-GENERATOR_NAME_TO_CHANNEL_ID: Final[Dict[GeneratorName, ChannelId]] = {
- GeneratorName.PULSE1: ChannelId.SQUARE1,
- GeneratorName.PULSE2: ChannelId.SQUARE2,
- GeneratorName.TRIANGLE: ChannelId.TRIANGLE,
- GeneratorName.NOISE: ChannelId.NOISE,
+CHANNEL_TO_ID: Final[Dict[ChannelName, ChannelId]] = {
+ ChannelName.PULSE1: ChannelId.SQUARE1,
+ ChannelName.PULSE2: ChannelId.SQUARE2,
+ ChannelName.TRIANGLE: ChannelId.TRIANGLE,
+ ChannelName.NOISE: ChannelId.NOISE,
}
diff --git a/src/sampletones_core/formats/famitracker/specification/parameters.py b/src/sampletones_core/formats/famitracker/specification/parameters.py
index 9788b081a..e1b0c5e2d 100644
--- a/src/sampletones_core/formats/famitracker/specification/parameters.py
+++ b/src/sampletones_core/formats/famitracker/specification/parameters.py
@@ -1,8 +1,6 @@
from enum import IntEnum
from typing import Final
-from sampletones_shared.application import SAMPLETONES_NAME
-
class Machine(IntEnum):
"""Playback machine stored in the PARAMS block."""
@@ -21,5 +19,4 @@ class Machine(IntEnum):
DEFAULT_SPEED_SPLIT_POINT: Final[int] = 32
-DEFAULT_COPYRIGHT: Final[str] = f"generated by {SAMPLETONES_NAME}"
COMMENT_HIDDEN_ON_OPEN: Final[int] = 0
diff --git a/src/sampletones_core/formats/famitracker/specification/sequences.py b/src/sampletones_core/formats/famitracker/specification/sequences.py
index a4df4a93b..43822ccf5 100644
--- a/src/sampletones_core/formats/famitracker/specification/sequences.py
+++ b/src/sampletones_core/formats/famitracker/specification/sequences.py
@@ -1,5 +1,5 @@
from enum import IntEnum
-from typing import Dict, Final
+from typing import Dict, Final, Tuple
from sampletones_core.constants.enums import FeatureKey
@@ -14,6 +14,13 @@ class SequenceKind(IntEnum):
DUTY = 4
+BEND_SEQUENCE_KINDS: Final[Tuple[SequenceKind, ...]] = (
+ SequenceKind.PITCH,
+ SequenceKind.HI_PITCH,
+)
+
+HI_PITCH_SEQUENCE_FACTOR: Final[int] = 16
+
SEQUENCE_COUNT_2A03: Final[int] = 5
MAX_SEQUENCES_PER_TYPE: Final[int] = 128
MAX_SEQUENCE_ITEMS: Final[int] = 252
diff --git a/src/sampletones_core/formats/famitracker/voice.py b/src/sampletones_core/formats/famitracker/voice.py
new file mode 100644
index 000000000..131ad5d71
--- /dev/null
+++ b/src/sampletones_core/formats/famitracker/voice.py
@@ -0,0 +1,155 @@
+from dataclasses import dataclass
+from enum import StrEnum
+from typing import Dict, Optional, Tuple
+
+from pydantic import ValidationError
+
+from sampletones_core.features.envelope import Envelope
+from sampletones_core.formats.famitracker.model.instrument import Instrument2A03
+from sampletones_core.formats.famitracker.model.sequence import InstrumentSequence
+from sampletones_core.formats.famitracker.specification.sequences import (
+ BEND_SEQUENCE_KINDS,
+ DEFAULT_SEQUENCE_SETTING,
+ LOOP_FROM_START,
+ NO_RELEASE_POINT,
+ SequenceKind,
+)
+from sampletones_core.project.voices.envelopes import InstrumentEnvelopes
+from sampletones_core.project.voices.instrument import Instrument
+from sampletones_shared.exceptions import InvalidInstrumentValuesError
+
+Sequences = Dict[SequenceKind, InstrumentSequence]
+
+
+class InstrumentOmission(StrEnum):
+ """What a tracker instrument states beyond the envelopes and loop points a voice holds."""
+
+ CUMULATIVE_BEND = "cumulative_bend"
+ RELEASE_POINT = "release_point"
+ ARPEGGIO_MODE = "arpeggio_mode"
+
+
+@dataclass(frozen=True)
+class ImportedVoice:
+ """A voice made from a tracker instrument, beside what the instrument stated past it.
+
+ Attributes:
+ voice: The instrument voice the envelopes describe.
+ omissions: What the tracker instrument carried that the voice leaves to the file.
+ """
+
+ voice: Instrument
+ omissions: Tuple[InstrumentOmission, ...]
+
+
+def instrument_to_voice(instrument: Instrument2A03) -> ImportedVoice:
+ """Makes a voice from a FamiTracker instrument, and names what the instrument stated past it.
+
+ A voice carries a volume, an arpeggio, a pitch, a hi-pitch and a duty-cycle envelope, each
+ with the item it repeats from, so those come across as they stand. A tracker instrument
+ states more than that — a release segment, an arpeggio mode, a bend the tracker accumulates —
+ and each of those is reported, so a reader learns what the file held.
+
+ The voice measures its arpeggio against the pitch a voice added by hand rests at, since a
+ tracker instrument sounds at whatever note a row names it with.
+
+ Args:
+ instrument: The instrument a ``.fti`` file or a module holds.
+
+ Returns:
+ ImportedVoice: The voice, and what it leaves to the instrument it came from.
+
+ Raises:
+ InvalidInstrumentValuesError: If a sequence carries an item outside the range the
+ dimension it feeds holds.
+ """
+ sequences = instrument.sequences
+
+ return ImportedVoice(
+ voice=_voice(instrument.name, sequences),
+ omissions=_omissions(sequences),
+ )
+
+
+def _voice(
+ name: str,
+ sequences: Sequences,
+) -> Instrument:
+ try:
+ envelopes = InstrumentEnvelopes(
+ volume=_envelope(sequences[SequenceKind.VOLUME]),
+ arpeggio=_envelope(sequences[SequenceKind.ARPEGGIO]),
+ pitch=_envelope(sequences[SequenceKind.PITCH]),
+ hi_pitch=_envelope(sequences[SequenceKind.HI_PITCH]),
+ duty_cycle=_envelope(sequences[SequenceKind.DUTY]),
+ )
+ except ValidationError as exception:
+ raise InvalidInstrumentValuesError(
+ f'Failed to read the envelopes of instrument "{name}" due to validation error: {exception}',
+ exception,
+ ) from exception
+
+ return Instrument(
+ name=name,
+ envelopes=envelopes,
+ )
+
+
+def _envelope(sequence: InstrumentSequence) -> Envelope[int]:
+ """One dimension as the voice holds it, carrying the point that sequence repeats from.
+
+ Args:
+ sequence: The sequence the file states for this dimension.
+
+ Returns:
+ Envelope[int]: Its items and its own loop point, empty where the file writes nothing.
+ """
+ return Envelope[int](
+ items=sequence.items,
+ loop_point=_loop_point(sequence),
+ )
+
+
+def _loop_point(sequence: InstrumentSequence) -> Optional[int]:
+ if sequence.loop_point < LOOP_FROM_START or sequence.loop_point >= len(sequence.items):
+ return None
+
+ return sequence.loop_point
+
+
+def _omissions(sequences: Sequences) -> Tuple[InstrumentOmission, ...]:
+ arpeggio = sequences[SequenceKind.ARPEGGIO]
+ held = {
+ InstrumentOmission.CUMULATIVE_BEND: _accumulates_bend(sequences),
+ InstrumentOmission.RELEASE_POINT: _holds_release_point(sequences),
+ InstrumentOmission.ARPEGGIO_MODE: arpeggio.enabled and arpeggio.setting != DEFAULT_SEQUENCE_SETTING,
+ }
+
+ return tuple(omission for omission, stated in held.items() if stated)
+
+
+def _accumulates_bend(sequences: Sequences) -> bool:
+ """Whether the tracker would let this file's bend pile up rather than offset each tick.
+
+ A bend item is an offset from the note wherever an absolute arpeggio reloads the period on the
+ same tick, and the tracker walks the arpeggio before the bend. A file whose arpeggio runs out
+ before its bend does therefore sounds the remaining items as a drift there, while a voice here
+ sounds each one as the offset it states. Reporting that is what tells a reader the two readings
+ part company.
+
+ Args:
+ sequences: The sequences the file states.
+
+ Returns:
+ bool: Whether the tracker accumulates any tick of this file's bend.
+ """
+ arpeggio = sequences[SequenceKind.ARPEGGIO]
+ if arpeggio.enabled and arpeggio.loop_point >= LOOP_FROM_START:
+ return False
+
+ covered = len(arpeggio.items) if arpeggio.enabled else 0
+ return any(len(sequences[kind].items) > covered for kind in BEND_SEQUENCE_KINDS if sequences[kind].enabled)
+
+
+def _holds_release_point(sequences: Sequences) -> bool:
+ return any(sequence.enabled and sequence.release_point != NO_RELEASE_POINT for sequence in sequences.values())
diff --git a/src/sampletones_core/generators/__init__.py b/src/sampletones_core/generators/__init__.py
index 95edc7d2b..92183c2f6 100644
--- a/src/sampletones_core/generators/__init__.py
+++ b/src/sampletones_core/generators/__init__.py
@@ -3,33 +3,38 @@
from .implementation.pulse import PulseGenerator
from .implementation.triangle import TriangleGenerator
from .maps import (
+ CHANNEL_CLASSES,
+ CLASS_NAME_TO_GENERATOR_MAP,
GENERATOR_CLASS_MAP,
- GENERATOR_CLASSES,
+ GENERATOR_TO_CLASS_NAME_MAP,
GENERATOR_TO_INSTRUCTION_MAP,
INSTRUCTION_TO_GENERATOR_MAP,
- LIBRARY_GENERATOR_CLASS_MAP,
MIXER_LEVELS,
)
+from .render import render_channels, render_instructions
+from .tonal import TonalGenerator
from .types import (
GeneratorClass,
GeneratorClassNames,
GeneratorT,
GeneratorTypeUnion,
GeneratorUnion,
+ TonalGeneratorUnion,
)
from .utils import (
get_generator_by_instruction,
- get_generators_by_names,
+ get_generators_by_channels,
get_generators_map,
get_remaining_generator_classes,
)
__all__ = [
- "GENERATOR_CLASSES",
+ "CHANNEL_CLASSES",
"GENERATOR_CLASS_MAP",
"GENERATOR_TO_INSTRUCTION_MAP",
"INSTRUCTION_TO_GENERATOR_MAP",
- "LIBRARY_GENERATOR_CLASS_MAP",
+ "CLASS_NAME_TO_GENERATOR_MAP",
+ "GENERATOR_TO_CLASS_NAME_MAP",
"MIXER_LEVELS",
"Generator",
"GeneratorClass",
@@ -39,9 +44,13 @@
"GeneratorUnion",
"NoiseGenerator",
"PulseGenerator",
+ "TonalGenerator",
+ "TonalGeneratorUnion",
"TriangleGenerator",
"get_generator_by_instruction",
- "get_generators_by_names",
+ "get_generators_by_channels",
"get_generators_map",
"get_remaining_generator_classes",
+ "render_channels",
+ "render_instructions",
]
diff --git a/src/sampletones_core/generators/implementation/noise.py b/src/sampletones_core/generators/implementation/noise.py
index 0eb662d6e..b30161838 100644
--- a/src/sampletones_core/generators/implementation/noise.py
+++ b/src/sampletones_core/generators/implementation/noise.py
@@ -3,7 +3,7 @@
import numpy as np
from sampletones_core.configs import Config
-from sampletones_core.constants.enums import GeneratorClassName, GeneratorName
+from sampletones_core.constants.enums import ChannelName, GeneratorClassName
from sampletones_core.constants.general import (
MAX_VOLUME,
MIXER_NOISE,
@@ -20,7 +20,7 @@ class NoiseGenerator(Generator[NoiseInstruction, LFSRTimer]):
def __init__(
self,
config: Config,
- name: str = GeneratorName.NOISE,
+ name: str = ChannelName.NOISE,
) -> None:
super().__init__(config, name)
self.timer = LFSRTimer(
diff --git a/src/sampletones_core/generators/implementation/pulse.py b/src/sampletones_core/generators/implementation/pulse.py
index 7ef85e4ee..8103cae42 100644
--- a/src/sampletones_core/generators/implementation/pulse.py
+++ b/src/sampletones_core/generators/implementation/pulse.py
@@ -3,7 +3,7 @@
import numpy as np
from sampletones_core.configs import Config
-from sampletones_core.constants.enums import GeneratorClassName, GeneratorName
+from sampletones_core.constants.enums import ChannelName, GeneratorClassName
from sampletones_core.constants.general import (
DUTY_CYCLES,
MAX_VOLUME,
@@ -14,14 +14,14 @@
from sampletones_core.timers import PhaseTimer
from sampletones_shared.types.data import Initials
-from ..generator import Generator
+from ..tonal import TonalGenerator
-class PulseGenerator(Generator[PulseInstruction, PhaseTimer]):
+class PulseGenerator(TonalGenerator[PulseInstruction]):
def __init__(
self,
config: Config,
- name: str = GeneratorName.PULSE1,
+ name: str = ChannelName.PULSE1,
) -> None:
super().__init__(config, name)
self.timer = PhaseTimer(
@@ -54,12 +54,6 @@ def __call__(
return output
- def set_timer(self, instruction: PulseInstruction) -> None:
- if instruction.on:
- self.timer.frequency = self.get_frequency(instruction.pitch)
- else:
- self.timer.frequency = 0.0
-
def apply(self, output: np.ndarray, instruction: PulseInstruction) -> np.ndarray:
duty_cycle = DUTY_CYCLES[instruction.duty_cycle]
output = np.where(output < duty_cycle, 1.0, -1.0).astype(np.float32)
diff --git a/src/sampletones_core/generators/implementation/triangle.py b/src/sampletones_core/generators/implementation/triangle.py
index 463ea4763..508624b36 100644
--- a/src/sampletones_core/generators/implementation/triangle.py
+++ b/src/sampletones_core/generators/implementation/triangle.py
@@ -3,7 +3,7 @@
import numpy as np
from sampletones_core.configs import Config
-from sampletones_core.constants.enums import GeneratorClassName, GeneratorName
+from sampletones_core.constants.enums import ChannelName, GeneratorClassName
from sampletones_core.constants.general import (
MIN_PITCH,
MIXER_TRIANGLE,
@@ -16,14 +16,14 @@
from sampletones_core.timers import PhaseTimer
from sampletones_shared.types.data import Initials
-from ..generator import Generator
+from ..tonal import TonalGenerator
-class TriangleGenerator(Generator[TriangleInstruction, PhaseTimer]):
+class TriangleGenerator(TonalGenerator[TriangleInstruction]):
def __init__(
self,
config: Config,
- name: str = GeneratorName.TRIANGLE,
+ name: str = ChannelName.TRIANGLE,
) -> None:
super().__init__(config, name)
self.timer = PhaseTimer(
@@ -56,12 +56,6 @@ def __call__(
return output
- def set_timer(self, instruction: TriangleInstruction) -> None:
- if instruction.on:
- self.timer.frequency = self.get_frequency(instruction.pitch)
- else:
- self.timer.frequency = 0.0
-
def apply(self, output: np.ndarray, instruction: TriangleInstruction) -> np.ndarray:
triangle = 1.0 - np.round(np.abs(((output + TRIANGLE_OFFSET) % 1.0) - 0.5) * 30.0) / 7.5
return (triangle * MIXER_TRIANGLE).astype(np.float32)
diff --git a/src/sampletones_core/generators/maps.py b/src/sampletones_core/generators/maps.py
index abc838b94..e7e1952d0 100644
--- a/src/sampletones_core/generators/maps.py
+++ b/src/sampletones_core/generators/maps.py
@@ -1,9 +1,9 @@
from typing import Dict, Final
from sampletones_core.constants.enums import (
+ ChannelName,
GeneratorClassName,
GeneratorName,
- LibraryGeneratorName,
)
from sampletones_core.constants.general import (
MIXER_NOISE,
@@ -22,18 +22,23 @@
from .implementation.triangle import TriangleGenerator
from .types import GeneratorTypeUnion
-LIBRARY_GENERATOR_CLASS_MAP: Final[Dict[LibraryGeneratorName, GeneratorClassName]] = {
- LibraryGeneratorName.PULSE: GeneratorClassName.PULSE_GENERATOR,
- LibraryGeneratorName.TRIANGLE: GeneratorClassName.TRIANGLE_GENERATOR,
- LibraryGeneratorName.NOISE: GeneratorClassName.NOISE_GENERATOR,
+GENERATOR_TO_CLASS_NAME_MAP: Final[Dict[GeneratorName, GeneratorClassName]] = {
+ GeneratorName.PULSE: GeneratorClassName.PULSE_GENERATOR,
+ GeneratorName.TRIANGLE: GeneratorClassName.TRIANGLE_GENERATOR,
+ GeneratorName.NOISE: GeneratorClassName.NOISE_GENERATOR,
}
-GENERATOR_CLASSES: Final[Dict[GeneratorName, GeneratorTypeUnion]] = {
- GeneratorName.PULSE1: PulseGenerator,
- GeneratorName.PULSE2: PulseGenerator,
- GeneratorName.TRIANGLE: TriangleGenerator,
- GeneratorName.NOISE: NoiseGenerator,
+CLASS_NAME_TO_GENERATOR_MAP: Final[Dict[GeneratorClassName, GeneratorName]] = {
+ class_name: generator_name for generator_name, class_name in GENERATOR_TO_CLASS_NAME_MAP.items()
+}
+
+
+CHANNEL_CLASSES: Final[Dict[ChannelName, GeneratorTypeUnion]] = {
+ ChannelName.PULSE1: PulseGenerator,
+ ChannelName.PULSE2: PulseGenerator,
+ ChannelName.TRIANGLE: TriangleGenerator,
+ ChannelName.NOISE: NoiseGenerator,
}
diff --git a/src/sampletones_core/generators/render.py b/src/sampletones_core/generators/render.py
new file mode 100644
index 000000000..076bef12a
--- /dev/null
+++ b/src/sampletones_core/generators/render.py
@@ -0,0 +1,59 @@
+from typing import Dict, Mapping, Sequence
+
+import numpy as np
+
+from sampletones_core.configs import Config
+from sampletones_core.constants.enums import ChannelName
+from sampletones_core.instructions import InstructionUnion
+
+from .maps import CHANNEL_CLASSES
+
+
+def render_instructions(
+ instructions: Sequence[InstructionUnion],
+ channel_name: ChannelName,
+ config: Config,
+) -> np.ndarray:
+ """One channel's audio, rendered frame by frame from the instructions that drive it.
+
+ Each frame continues the oscillator the one before it left running, so a note held across
+ frames sounds as a single tone. An instruction spans `config.frame_length` samples, which is
+ what ties the rendered length to the rate the configuration runs at.
+
+ Args:
+ instructions: The channel's instructions, one per frame.
+ channel_name: The channel the instructions drive.
+ config: The configuration the frames are rendered at.
+
+ Returns:
+ np.ndarray: The channel's waveform, one frame per instruction.
+
+ Raises:
+ ValueError: If the channel describes no frame.
+ """
+ generator = CHANNEL_CLASSES[channel_name](config, channel_name.value)
+ frames = [generator(instruction, save=True) for instruction in instructions] # type: ignore[arg-type]
+ return np.concatenate(frames)
+
+
+def render_channels(
+ instructions: Mapping[ChannelName, Sequence[InstructionUnion]],
+ config: Config,
+) -> Dict[ChannelName, np.ndarray]:
+ """The audio of every channel that describes a frame, in channel order.
+
+ A channel standing by renders nothing, so what comes back names the channels that sound and
+ the waveform each of them plays.
+
+ Args:
+ instructions: The instructions each channel is driven by.
+ config: The configuration the frames are rendered at.
+
+ Returns:
+ Dict[ChannelName, np.ndarray]: The waveform each sounding channel renders to.
+ """
+ return {
+ channel_name: render_instructions(instructions[channel_name], channel_name, config)
+ for channel_name in ChannelName.items()
+ if instructions.get(channel_name)
+ }
diff --git a/src/sampletones_core/generators/tonal.py b/src/sampletones_core/generators/tonal.py
new file mode 100644
index 000000000..a44096580
--- /dev/null
+++ b/src/sampletones_core/generators/tonal.py
@@ -0,0 +1,108 @@
+from abc import ABC
+from typing import Dict, Tuple, TypeVar
+
+from sampletones_core.configs import Config
+from sampletones_core.constants.general import MAX_TIMER, MIN_TIMER
+from sampletones_core.instructions import TonalInstruction
+from sampletones_core.timers import PhaseTimer, frequency_to_timer, timer_to_frequency
+from sampletones_shared.utils.arrays import clamp
+
+from .generator import Generator
+
+TonalInstructionT = TypeVar("TonalInstructionT", bound=TonalInstruction)
+
+
+class TonalGenerator(Generator[TonalInstructionT, PhaseTimer], ABC):
+ """A generator whose channel names a note, reached by loading a divider.
+
+ The pulse and triangle channels sound a pitch the same way — the note resolves to a divider,
+ and the frame's own bend moves it from there — so both read one table and drive their timer
+ through one call. The divider stays inside the range the register holds and away from the
+ value that stops the waveform, which keeps a bend audible wherever it lands.
+ """
+
+ def __init__(self, config: Config, name: str) -> None:
+ super().__init__(config, name)
+ self.timer_table: Dict[int, int] = {
+ pitch: frequency_to_timer(frequency) for pitch, frequency in self.frequency_table.items()
+ }
+
+ def set_timer(self, instruction: TonalInstructionT) -> None:
+ if instruction.on:
+ self.timer.timer = self.get_timer(instruction.pitch, instruction.timer_offset)
+ else:
+ self.timer.frequency = 0.0
+
+ def get_timer(self, pitch: int, offset: int) -> int:
+ """The divider a note sounds at once the frame's bend has moved it.
+
+ Args:
+ pitch: The note the frame names.
+ offset: The divider steps the frame is bent by.
+
+ Returns:
+ int: The divider to run at, within the range the register holds.
+
+ Raises:
+ KeyError: If the pitch is absent from the generator's tables.
+ """
+ return int(clamp(self.timer_table[pitch] + offset, MIN_TIMER, MAX_TIMER))
+
+ def sounds_at(self, pitch: int, offset: int) -> float:
+ """The frequency in Hz this channel sounds a note at once a bend has moved it.
+
+ A channel's waveform completes once per divider period times whatever the timer's own
+ stride is, so this is where a divider becomes the pitch a listener hears — the triangle
+ striding half as fast as the pulse and sounding an octave below the same divider.
+
+ Args:
+ pitch: The note the frame names.
+ offset: The divider steps the frame is bent by.
+
+ Returns:
+ float: The frequency the channel sounds.
+ """
+ return timer_to_frequency(self.get_timer(pitch, offset)) * self.timer.phase_increment
+
+ def bend_towards(self, pitch: int, frequency: float) -> int:
+ """The bend that lands this note nearest a frequency, held inside the note's own room.
+
+ A note owns half the dividers between itself and each neighbor, which is what tiles the
+ whole divider range across the notes with none of it out of reach and none of it claimed
+ twice. A frequency past that room takes the nearest bend the note offers, and the note
+ beside it is the one that reaches further.
+
+ Args:
+ pitch: The note the frame names.
+ frequency: The frequency in Hz the frame should sound at.
+
+ Returns:
+ int: The divider steps to bend by.
+ """
+ if frequency <= 0.0:
+ return 0
+
+ lowest, highest = self.bend_range(pitch)
+ wanted = frequency_to_timer(frequency / self.timer.phase_increment) - self.timer_table[pitch]
+ return int(clamp(wanted, lowest, highest))
+
+ def bend_range(self, pitch: int) -> Tuple[int, int]:
+ """The divider steps a bend may move a note, half the gap to each neighboring note.
+
+ Args:
+ pitch: The note the frame names.
+
+ Returns:
+ Tuple[int, int]: The lowest and highest bend the note offers.
+ """
+ divider = self.timer_table[pitch]
+ below = self.timer_table.get(pitch - 1, divider + self._gap(pitch, pitch + 1))
+ above = self.timer_table.get(pitch + 1, divider - self._gap(pitch - 1, pitch))
+ return -((divider - above) // 2), (below - divider) // 2
+
+ def _gap(self, lower: int, higher: int) -> int:
+ """The dividers between two neighboring notes, where the table reaches both."""
+ if lower not in self.timer_table or higher not in self.timer_table:
+ return 0
+
+ return self.timer_table[lower] - self.timer_table[higher]
diff --git a/src/sampletones_core/generators/types.py b/src/sampletones_core/generators/types.py
index 671c5034a..6a294bced 100644
--- a/src/sampletones_core/generators/types.py
+++ b/src/sampletones_core/generators/types.py
@@ -9,5 +9,6 @@
GeneratorT = TypeVar("GeneratorT", PulseGenerator, TriangleGenerator, NoiseGenerator)
GeneratorClass = Type[GeneratorT]
GeneratorUnion = Union[PulseGenerator, TriangleGenerator, NoiseGenerator]
+TonalGeneratorUnion = Union[PulseGenerator, TriangleGenerator]
GeneratorTypeUnion = Union[Type[PulseGenerator], Type[TriangleGenerator], Type[NoiseGenerator]]
GeneratorClassNames = Union[GeneratorClassName, Tuple[GeneratorClassName, ...]]
diff --git a/src/sampletones_core/generators/utils.py b/src/sampletones_core/generators/utils.py
index c1c78308e..28f827389 100644
--- a/src/sampletones_core/generators/utils.py
+++ b/src/sampletones_core/generators/utils.py
@@ -1,30 +1,30 @@
from typing import Dict, List
from sampletones_core.configs import Config
-from sampletones_core.constants.enums import GeneratorClassName, GeneratorName
+from sampletones_core.constants.enums import ChannelName, GeneratorClassName
from sampletones_core.instructions import (
INSTRUCTION_CLASS_MAP,
InstructionUnion,
)
from .maps import (
+ CHANNEL_CLASSES,
GENERATOR_CLASS_MAP,
- GENERATOR_CLASSES,
INSTRUCTION_TO_GENERATOR_MAP,
)
from .types import GeneratorUnion
-def get_generators_by_names(
+def get_generators_by_channels(
config: Config,
- generator_names: List[GeneratorName],
-) -> Dict[GeneratorName, GeneratorUnion]:
- names = generator_names.copy()
- if GeneratorName.PULSE2 in names and not GeneratorName.PULSE1 in names:
- names.remove(GeneratorName.PULSE2)
- names.insert(0, GeneratorName.PULSE1)
+ channel_names: List[ChannelName],
+) -> Dict[ChannelName, GeneratorUnion]:
+ names = channel_names.copy()
+ if ChannelName.PULSE2 in names and not ChannelName.PULSE1 in names:
+ names.remove(ChannelName.PULSE2)
+ names.insert(0, ChannelName.PULSE1)
- return {name: GENERATOR_CLASSES[name](config, name) for name in names}
+ return {name: CHANNEL_CLASSES[name](config, name) for name in names}
def get_generators_map(
@@ -34,9 +34,21 @@ def get_generators_map(
def get_remaining_generator_classes(
- remaining_generators: Dict[GeneratorName, GeneratorUnion],
+ remaining_channels: Dict[ChannelName, GeneratorUnion],
) -> Dict[GeneratorClassName, GeneratorUnion]:
- return {generator.class_name(): generator for generator in reversed(remaining_generators.values())}
+ """
+ Maps each remaining generator class to its representative channel generator.
+
+ Channels of one kind share a candidate catalog, so one channel stands for the
+ kind while its candidates are scored. The lowest remaining channel of a kind is
+ its representative, which resolves successive picks over same-kind channels to
+ the lowest free channel deterministically.
+ """
+ return {
+ remaining_channels[name].class_name(): remaining_channels[name]
+ for name in reversed(ChannelName.items())
+ if name in remaining_channels
+ }
def get_generator_by_instruction(
diff --git a/src/sampletones_core/instructions/__init__.py b/src/sampletones_core/instructions/__init__.py
index 7360d224f..e00bb1e66 100644
--- a/src/sampletones_core/instructions/__init__.py
+++ b/src/sampletones_core/instructions/__init__.py
@@ -4,12 +4,14 @@
from .implementation.triangle import TriangleInstruction
from .instruction import Instruction
from .maps import INSTRUCTION_CLASS_MAP
+from .tonal import TonalInstruction
from .types import (
InstructionClass,
InstructionFields,
InstructionT,
InstructionTypeUnion,
InstructionUnion,
+ TonalInstructionUnion,
)
from .utils import get_instruction_by_type
@@ -24,6 +26,8 @@
"InstructionUnion",
"NoiseInstruction",
"PulseInstruction",
+ "TonalInstruction",
+ "TonalInstructionUnion",
"TriangleInstruction",
"get_instruction_by_type",
]
diff --git a/src/sampletones_core/instructions/implementation/pulse.py b/src/sampletones_core/instructions/implementation/pulse.py
index 494f21335..eea06a967 100644
--- a/src/sampletones_core/instructions/implementation/pulse.py
+++ b/src/sampletones_core/instructions/implementation/pulse.py
@@ -6,7 +6,6 @@
from sampletones_core.constants.general import (
DUTY_CYCLES,
MAX_DUTY_CYCLE,
- MAX_PITCH,
MAX_VOLUME,
MIN_PITCH,
PITCH_RANGE,
@@ -14,10 +13,10 @@
from sampletones_core.utils.frequencies import pitch_to_name
from ..instruction import Instruction
+from ..tonal import TonalInstruction
-class PulseInstruction(Instruction):
- pitch: int = Field(..., ge=MIN_PITCH, le=MAX_PITCH, description="MIDI pitch (0-120)")
+class PulseInstruction(TonalInstruction):
volume: int = Field(..., ge=0, le=MAX_VOLUME, description="Volume (0-15)")
duty_cycle: int = Field(
...,
diff --git a/src/sampletones_core/instructions/implementation/triangle.py b/src/sampletones_core/instructions/implementation/triangle.py
index fa3112f74..c535c4e48 100644
--- a/src/sampletones_core/instructions/implementation/triangle.py
+++ b/src/sampletones_core/instructions/implementation/triangle.py
@@ -1,17 +1,14 @@
from __future__ import annotations
-from pydantic import Field
-
from sampletones_core.constants.enums import InstructionClassName
-from sampletones_core.constants.general import MAX_PITCH, MIN_PITCH, PITCH_RANGE
+from sampletones_core.constants.general import MIN_PITCH, PITCH_RANGE
from sampletones_core.utils.frequencies import pitch_to_name
from ..instruction import Instruction
+from ..tonal import TonalInstruction
-class TriangleInstruction(Instruction):
- pitch: int = Field(..., ge=MIN_PITCH, le=MAX_PITCH, description="MIDI pitch (0-120)")
-
+class TriangleInstruction(TonalInstruction):
@property
def name(self) -> str:
pitch = pitch_to_name(self.pitch)
diff --git a/src/sampletones_core/instructions/instruction.py b/src/sampletones_core/instructions/instruction.py
index 3f52b666c..65882da47 100644
--- a/src/sampletones_core/instructions/instruction.py
+++ b/src/sampletones_core/instructions/instruction.py
@@ -9,10 +9,10 @@
class Instruction(DataModel, ABC):
"""
- A single-frame command that drives one generator channel.
+ A single-frame command that drives one NES channel.
Each concrete instruction (pulse, triangle, noise) carries the parameters its
- generator reads to synthesize one frame of audio — pitch, volume, and any timbre
+ channel reads to synthesize one frame of audio — pitch, volume, and any timbre
controls — together with the ``on`` flag that decides whether the channel sounds.
Instances are immutable and validated, so an instruction is a stable value safe to
store, hash, and compare.
diff --git a/src/sampletones_core/instructions/tonal.py b/src/sampletones_core/instructions/tonal.py
new file mode 100644
index 000000000..79edf842b
--- /dev/null
+++ b/src/sampletones_core/instructions/tonal.py
@@ -0,0 +1,54 @@
+from abc import ABC
+
+from pydantic import Field
+
+from sampletones_core.constants.general import (
+ HI_PITCH_FACTOR,
+ MAX_PITCH,
+ MIN_PITCH,
+ PITCH_BEND_MAX,
+ PITCH_BEND_MIN,
+)
+
+from .instruction import Instruction
+
+
+class TonalInstruction(Instruction, ABC):
+ """An instruction naming a note, sounded through a timer the frame may bend.
+
+ The pulse and triangle channels reach a pitch by loading a timer, and the timer grid is finer
+ than the semitone grid everywhere below the top of the range: a step spans well under a cent
+ at the lowest notes and widens to a whole semitone at the highest. A frame therefore states
+ the note it plays and, beside it, the offset in timer steps that carries it off the
+ equal-tempered grid — the two dimensions FamiTracker writes as its pitch and hi-pitch
+ sequences, one step apiece and one step of sixteen.
+
+ Attributes:
+ pitch: The note the frame names.
+ detune: Timer steps the frame is bent by, one step per unit.
+ coarse_detune: Timer steps the frame is bent by, sixteen steps per unit.
+ """
+
+ pitch: int = Field(..., ge=MIN_PITCH, le=MAX_PITCH, description="The note the frame names")
+ detune: int = Field(
+ default=0,
+ ge=PITCH_BEND_MIN,
+ le=PITCH_BEND_MAX,
+ description="Timer steps the frame is bent by, one step per unit",
+ )
+ coarse_detune: int = Field(
+ default=0,
+ ge=PITCH_BEND_MIN,
+ le=PITCH_BEND_MAX,
+ description="Timer steps the frame is bent by, sixteen steps per unit",
+ )
+
+ @property
+ def timer_offset(self) -> int:
+ """The timer steps this frame stands away from its note, both dimensions together."""
+ return self.detune + HI_PITCH_FACTOR * self.coarse_detune
+
+ @property
+ def bent(self) -> bool:
+ """Whether the frame sounds anywhere other than its note's own timer."""
+ return self.timer_offset != 0
diff --git a/src/sampletones_core/instructions/types.py b/src/sampletones_core/instructions/types.py
index 58b8a9002..643facb2b 100644
--- a/src/sampletones_core/instructions/types.py
+++ b/src/sampletones_core/instructions/types.py
@@ -6,8 +6,18 @@
from .instruction import Instruction
InstructionT = TypeVar("InstructionT", bound=Instruction)
+TonalInstructionUnion = Union[PulseInstruction, TriangleInstruction]
InstructionClass = Type[InstructionT]
InstructionUnion = Union[PulseInstruction, TriangleInstruction, NoiseInstruction]
InstructionTypeUnion = Union[Type[PulseInstruction], Type[TriangleInstruction], Type[NoiseInstruction]]
-InstructionFields = Literal["on", "volume", "pitch", "duty_cycle", "period", "short"]
+InstructionFields = Literal[
+ "on",
+ "volume",
+ "pitch",
+ "detune",
+ "coarse_detune",
+ "duty_cycle",
+ "period",
+ "short",
+]
diff --git a/src/sampletones_core/library/data.py b/src/sampletones_core/library/data.py
index c62d07e63..3f19a6541 100644
--- a/src/sampletones_core/library/data.py
+++ b/src/sampletones_core/library/data.py
@@ -6,6 +6,8 @@
from pydantic import ConfigDict, Field, ValidationError
+from sampletones_core.compatibility.kind import ObjectKind
+from sampletones_core.compatibility.upgrade import upgrade_binary
from sampletones_core.configs import Config, InstructionsLibraryConfig
from sampletones_core.constants.enums import GeneratorClassName
from sampletones_core.data import DataModel, Metadata, MetadataContract
@@ -116,6 +118,7 @@ def load(cls, path: Pathlike, fast: bool = True) -> InstructionLibraryData:
binary = load_binary(path)
try:
+ binary = upgrade_binary(ObjectKind.LIBRARY, binary)
return InstructionLibraryData.deserialize(
binary,
validation=cls.validate_metadata,
diff --git a/src/sampletones_core/parallelization/channel/__init__.py b/src/sampletones_core/parallelization/channel/__init__.py
new file mode 100644
index 000000000..095a5f639
--- /dev/null
+++ b/src/sampletones_core/parallelization/channel/__init__.py
@@ -0,0 +1,11 @@
+from .process import ProcessProgressChannel, QueueStepReporter
+from .protocol import ProgressChannel, StepReporter
+from .pump import ProgressPump
+
+__all__ = [
+ "ProcessProgressChannel",
+ "ProgressChannel",
+ "ProgressPump",
+ "QueueStepReporter",
+ "StepReporter",
+]
diff --git a/src/sampletones_core/parallelization/channel/process.py b/src/sampletones_core/parallelization/channel/process.py
new file mode 100644
index 000000000..340745d83
--- /dev/null
+++ b/src/sampletones_core/parallelization/channel/process.py
@@ -0,0 +1,77 @@
+import multiprocessing
+import queue
+import threading
+from multiprocessing.managers import SyncManager
+from typing import Final, Optional
+
+from sampletones_core.parallelization.channel.protocol import StepReporter
+from sampletones_core.parallelization.task import TaskReport, TaskStep
+
+SPAWN_CONTEXT: Final[str] = "spawn"
+
+
+class QueueStepReporter:
+ """The line one task reports on, made of a queue up and a flag down.
+
+ Both ends are manager proxies, so the pair travels to a worker with the task it belongs to and
+ reconnects there over the manager's own socket. That is what lets a worker started as a fresh
+ interpreter — which is how every platform this runs on starts one — reach the run that sent it.
+ """
+
+ def __init__(
+ self,
+ reports: "queue.Queue[TaskReport]",
+ withdrawn: threading.Event,
+ index: int,
+ ) -> None:
+ self._reports = reports
+ self._withdrawn = withdrawn
+ self._index = index
+
+ def __call__(self, step: TaskStep) -> bool:
+ """Files the step under the task this line belongs to, and answers whether the run goes on.
+
+ Args:
+ step: Where the task now stands.
+
+ Returns:
+ bool: Whether the run still wants the answer this task is building.
+ """
+ self._reports.put(TaskReport(index=self._index, step=step))
+ return not self._withdrawn.is_set()
+
+
+class ProcessProgressChannel:
+ """The line a run holds with tasks running in worker processes.
+
+ A manager stands beside the pool and owns both ends, so each end reaches a task as an ordinary
+ value the task is built with. The manager runs under the same spawn context the pool's workers
+ do, which keeps one way of starting a process across the whole run.
+
+ The channel is opened by the run that hands the lines out and closed by it once every task has
+ been heard from, since the manager is a process of its own to be reaped.
+ """
+
+ def __init__(self) -> None:
+ self._manager: SyncManager = multiprocessing.get_context(SPAWN_CONTEXT).Manager()
+ self._reports: "queue.Queue[TaskReport]" = self._manager.Queue()
+ self._withdrawn: threading.Event = self._manager.Event()
+
+ def reporter(self, index: int) -> StepReporter:
+ """The line the task at ``index`` reports on, which travels to its worker with it."""
+ return QueueStepReporter(self._reports, self._withdrawn, index)
+
+ def poll(self, timeout: float) -> Optional[TaskReport]:
+ """The next report a task filed, waiting up to ``timeout`` seconds for one to arrive."""
+ try:
+ return self._reports.get(timeout=timeout)
+ except queue.Empty:
+ return None
+
+ def withdraw(self) -> None:
+ """Tells every task the run has let go of the answer it was building."""
+ self._withdrawn.set()
+
+ def close(self) -> None:
+ """Ends the channel and reaps the manager process that held its ends."""
+ self._manager.shutdown()
diff --git a/src/sampletones_core/parallelization/channel/protocol.py b/src/sampletones_core/parallelization/channel/protocol.py
new file mode 100644
index 000000000..5376ce09b
--- /dev/null
+++ b/src/sampletones_core/parallelization/channel/protocol.py
@@ -0,0 +1,37 @@
+from typing import Optional, Protocol
+
+from sampletones_core.parallelization.task import TaskReport, TaskStep
+
+
+class StepReporter(Protocol):
+ """A task's line back to the run that started it.
+
+ A task says where it stands and hears whether the run still wants its answer, which is the
+ contract a run inside one process already reports itself by; what a line adds is the distance
+ the report travels. One line serves one task, so a report names the task it came from without
+ that task holding an opinion on its own place in the run.
+ """
+
+ def __call__(self, step: TaskStep) -> bool:
+ """Carries the step, and answers whether the run goes on."""
+
+
+class ProgressChannel(Protocol):
+ """The line a run and the tasks it handed out hold each other on.
+
+ Progress travels one way and a withdrawal the other, so one channel carries both directions of
+ the conversation. What a channel is made of follows from where its tasks run, which is why a
+ run reaches its tasks through this contract rather than through any one way of crossing to them.
+ """
+
+ def reporter(self, index: int) -> StepReporter:
+ """The line the task at ``index`` reports on, which travels to wherever that task runs."""
+
+ def poll(self, timeout: float) -> Optional[TaskReport]:
+ """The next report a task filed, waiting up to ``timeout`` seconds for one to arrive."""
+
+ def withdraw(self) -> None:
+ """Tells every task the run has let go of the answer it was building."""
+
+ def close(self) -> None:
+ """Ends the channel and releases whatever it held open for the tasks to reach."""
diff --git a/src/sampletones_core/parallelization/channel/pump.py b/src/sampletones_core/parallelization/channel/pump.py
new file mode 100644
index 000000000..ae8ea75b9
--- /dev/null
+++ b/src/sampletones_core/parallelization/channel/pump.py
@@ -0,0 +1,75 @@
+import threading
+from typing import Callable, Final
+
+from sampletones_core.parallelization.channel.protocol import ProgressChannel
+from sampletones_core.parallelization.task import TaskReport
+from sampletones_shared.logger import LoggerProtocol
+from sampletones_shared.types.callback import VoidCallback
+
+POLL_SECONDS: Final[float] = 0.05
+DRAIN_SECONDS: Final[float] = 0.0
+JOIN_SECONDS: Final[float] = 1.0
+PUMP_THREAD_NAME: Final[str] = "TaskProcessorProgress"
+
+
+class ProgressPump:
+ """Carries what the tasks report to whoever is watching the run.
+
+ A run's monitor thread waits on the results the pool hands back, so a report arriving between
+ two of them is heard on a thread of its own. Everything already waiting is taken in one turn
+ and announced once, which holds the announcements to the rate this reads at however many steps
+ the tasks file in between — the reading a bar wants, at a rate a bar can be redrawn at.
+ """
+
+ def __init__(
+ self,
+ channel: ProgressChannel,
+ *,
+ record: Callable[[TaskReport], None],
+ announce: VoidCallback,
+ logger: LoggerProtocol,
+ ) -> None:
+ """Holds the one thread that reads a channel.
+
+ Args:
+ channel: The line the tasks report on.
+ record: Takes each report as where the task that filed it now stands.
+ announce: Tells whoever is watching that the run has moved.
+ logger: Hears a channel that ends before the pump is asked to stop.
+ """
+ self._channel = channel
+ self._record = record
+ self._announce = announce
+ self._logger = logger
+ self._stopped = threading.Event()
+ self._thread = threading.Thread(target=self._read, daemon=True, name=PUMP_THREAD_NAME)
+
+ def start(self) -> None:
+ """Begins reading the channel."""
+ self._thread.start()
+
+ def stop(self) -> None:
+ """Asks the reading to end, and waits for the thread to leave the channel alone."""
+ self._stopped.set()
+ if self._thread.is_alive():
+ self._thread.join(timeout=JOIN_SECONDS)
+
+ def _read(self) -> None:
+ while not self._stopped.is_set():
+ try:
+ report = self._channel.poll(POLL_SECONDS)
+ if report is None:
+ continue
+
+ self._record(report)
+ self._drain()
+ except (EOFError, OSError) as exception:
+ self._logger.warning(f"Progress channel ended while the run was still reading: {exception}")
+ return
+
+ self._announce()
+
+ def _drain(self) -> None:
+ """Takes every report already waiting, so one turn of the loop announces them together."""
+ while (report := self._channel.poll(DRAIN_SECONDS)) is not None:
+ self._record(report)
diff --git a/src/sampletones_core/parallelization/processor.py b/src/sampletones_core/parallelization/processor.py
index a377bf3ce..489318230 100644
--- a/src/sampletones_core/parallelization/processor.py
+++ b/src/sampletones_core/parallelization/processor.py
@@ -7,10 +7,15 @@
from pebble import ProcessMapFuture, ProcessPool
from sampletones_core.constants.algorithm import MAX_WORKERS
+from sampletones_core.parallelization.channel.process import ProcessProgressChannel
+from sampletones_core.parallelization.channel.protocol import ProgressChannel, StepReporter
+from sampletones_core.parallelization.channel.pump import ProgressPump
+from sampletones_core.parallelization.steps import TaskSteps
from sampletones_core.parallelization.task import (
TaskProgress,
TaskStatus,
)
+from sampletones_shared.exceptions import OperationCanceled
from sampletones_shared.logger import LoggerProtocol
from sampletones_shared.logger import logger as default_logger
from sampletones_shared.types.callback import Callback, VoidCallback
@@ -45,11 +50,16 @@ def __init__(
self._pool_lock: threading.Lock = threading.Lock()
self._exception: Optional[Exception] = None
+ self._steps: TaskSteps = TaskSteps()
+ self._channel_lock: threading.Lock = threading.Lock()
+ self._channel: Optional[ProgressChannel] = None
+ self._pump: Optional[ProgressPump] = None
+
self.on_start: Optional[VoidCallback] = None
self.on_progress: Optional[Callable[[TaskStatus, TaskProgress], None]] = None
self.on_completed: Optional[Callable[[T], None]] = None
self.on_error: Optional[Callable[[Exception], None]] = None
- self.on_cancelled: Optional[VoidCallback] = None
+ self.on_canceled: Optional[VoidCallback] = None
def start(self) -> None:
self.monitor_thread = threading.Thread(
@@ -72,6 +82,7 @@ def cleanup(self) -> None:
self.running = False
self.cancelling = True
+ self._withdraw()
self._notify_progress()
self._cleanup()
@@ -79,6 +90,7 @@ def cancel(self) -> None:
self.status = TaskStatus.CANCELLING
self.cancelling = True
+ self._withdraw()
self._notify_progress()
self._cleanup()
@@ -93,12 +105,14 @@ def shutdown(self) -> None:
self.running = False
self.cancelling = True
+ self._withdraw()
self._notify_progress()
if self.future is not None:
self.future.cancel()
self._stop_pool()
self._join_thread()
+ self._release_channel()
self._reset_status()
def is_running(self) -> bool:
@@ -107,8 +121,8 @@ def is_running(self) -> bool:
def is_completed(self) -> bool:
return self.status == TaskStatus.COMPLETED
- def is_cancelled(self) -> bool:
- return self.status == TaskStatus.CANCELLED
+ def is_canceled(self) -> bool:
+ return self.status == TaskStatus.CANCELED
def is_cancelling(self) -> bool:
return self.status == TaskStatus.CANCELLING
@@ -128,6 +142,56 @@ def _get_task_function(self) -> Callback: ...
@abstractmethod
def _process_results(self, results: List[T]) -> Any: ...
+ def _task_reporter(self, index: int) -> StepReporter:
+ """The line the task at ``index`` reports its own progress on.
+
+ A subclass whose tasks find their way through work the run cannot see asks for one per task
+ as it builds them. The channel those lines run through is opened on the first ask, so a run
+ whose tasks report nothing costs nothing to listen to.
+ """
+ with self._channel_lock:
+ if self._channel is None:
+ self._channel = ProcessProgressChannel()
+
+ return self._channel.reporter(index)
+
+ def _withdraw(self) -> None:
+ """Tells the running tasks the run has let go of the answer they were building."""
+ with self._channel_lock:
+ if self._channel is not None:
+ self._channel.withdraw()
+
+ def _start_pump(self) -> None:
+ with self._channel_lock:
+ if self._channel is None:
+ return
+
+ self._pump = ProgressPump(
+ self._channel,
+ record=self._steps.record,
+ announce=self._notify_progress,
+ logger=self.logger,
+ )
+
+ self._pump.start()
+
+ def _release_channel(self) -> None:
+ """Ends the reading and the channel, which the run does once its tasks are all heard from.
+
+ Every way a run can end reaches here, including one that built its tasks and never started,
+ since the channel is a process of its own to be reaped whatever became of the run.
+ """
+ with self._channel_lock:
+ if self._pump is not None:
+ self._pump.stop()
+ self._pump = None
+
+ if self._channel is not None:
+ self._channel.close()
+ self._channel = None
+
+ self._steps.clear()
+
def _reset_status(self) -> None:
self.status = TaskStatus.PENDING
self.running = False
@@ -137,6 +201,13 @@ def _reset_status(self) -> None:
self.current_item = None
def _run_tasks(self) -> None:
+ """Runs the tasks and holds the progress channel for exactly as long as they do."""
+ try:
+ self._process_tasks()
+ finally:
+ self._release_channel()
+
+ def _process_tasks(self) -> None:
self._reset_status()
try:
@@ -156,6 +227,7 @@ def _run_tasks(self) -> None:
self.pool = ProcessPool(max_workers=workers, context=context)
task_function = self._get_task_function()
self.future = self.pool.map(task_function, tasks, timeout=None)
+ self._start_pump()
self.call(self.on_start)
results = []
@@ -171,12 +243,17 @@ def _run_tasks(self) -> None:
result = next(iterator)
results.append(result)
+ self._steps.complete(self.completed_tasks)
self.completed_tasks += 1
self._notify_progress()
except StopIteration:
pass
except KeyboardInterrupt as exception:
raise CancelledError() from exception
+ except OperationCanceled:
+ self.cancelling = True
+ self._finalize_cancellation()
+ return
except CancelledError:
self._finalize_cancellation()
return
@@ -197,6 +274,7 @@ def _notify_progress(self) -> None:
total=self.total_tasks,
completed=self.completed_tasks,
current_item=self.current_item,
+ steps=self._steps.snapshot(),
)
self.call(self.on_progress, self.status, progress)
@@ -206,12 +284,12 @@ def _finalize_cancellation(self) -> None:
if not self.cancelling:
return
- self.logger.info("Task processing was cancelled.")
- self.status = TaskStatus.CANCELLED
+ self.logger.info("Task processing was canceled.")
+ self.status = TaskStatus.CANCELED
self.cancelling = False
self.running = False
self._notify_progress()
- self.call(self.on_cancelled)
+ self.call(self.on_canceled)
def _finalize_completion(self, results: List[T]) -> None:
self.logger.info("Conversion completed successfully")
@@ -254,6 +332,7 @@ def _join_thread(self) -> None:
def _wait_for_cleanup(self) -> None:
self._stop_pool()
self._join_thread()
+ self._release_channel()
self._reset_status()
def _complete_process(self, results: List[T]) -> None:
diff --git a/src/sampletones_core/parallelization/progress.py b/src/sampletones_core/parallelization/progress.py
index f95759630..19bf09379 100644
--- a/src/sampletones_core/parallelization/progress.py
+++ b/src/sampletones_core/parallelization/progress.py
@@ -1,77 +1,84 @@
-from collections import deque
-from time import monotonic
-from typing import Deque, Final, Optional, Tuple
-
-ESTIMATION_MEASUREMENTS_SAMPLES: Final[float] = 0.05
-
-
-class ETAEstimator:
- def __init__(
- self,
- total: int,
- ems: float = ESTIMATION_MEASUREMENTS_SAMPLES,
- ) -> None:
- self._total = total
- self._ems = self._get_estimation_measurements_samples(ems)
- self._samples_window: Deque[Tuple[float, int]] = deque(maxlen=self._ems)
- self._processed_items: int = 0
-
- def update(self, completed_items: int) -> Optional[float]:
- now = monotonic()
- self._processed_items = completed_items
- self._samples_window.append((now, completed_items))
-
- if completed_items >= self._total:
- return 0.0
- if len(self._samples_window) < 2:
- return None
-
- return self._estimate_remaining_seconds(completed_items, now)
-
- @classmethod
- def format_duration(cls, seconds: Optional[float]) -> str:
- if seconds is None:
- return "?"
- if seconds <= 0:
- return "0s"
-
- secs = int(seconds)
- if secs <= 0:
- return "0s"
-
- minutes, seconds_remaining = divmod(secs, 60)
- hours, minutes = divmod(minutes, 60)
-
- if hours:
- return f"{hours}h {minutes:02d}m {seconds_remaining:02d}s"
- if minutes:
- return f"{minutes}m {seconds_remaining:02d}s"
-
- return f"{seconds_remaining}s"
-
- def _get_estimation_measurements_samples(self, ems: float) -> int:
- if isinstance(ems, float):
- ems = round(ems * self._total)
-
- return max(3, int(ems))
-
- def _estimate_remaining_seconds(
- self,
- completed_items: int,
- current_time: float,
- ) -> Optional[float]:
- if completed_items >= self._total:
- return 0.0
- if len(self._samples_window) < 2:
- return None
-
- first_time, first_completed = self._samples_window[0]
- delta_completed = completed_items - first_completed
- delta_time = current_time - first_time
-
- if delta_time <= 0 or delta_completed <= 0:
- return None
-
- rate = delta_completed / delta_time
- remaining = self._total - completed_items
- return remaining / rate
+from collections import deque
+from time import monotonic
+from typing import Deque, Final, Optional, Tuple, Union
+
+ESTIMATION_MEASUREMENTS_SAMPLES: Final[float] = 0.05
+
+
+class ETAEstimator:
+ """How long a run has left, read from the rate it has been covering its work at.
+
+ What a run has covered is a measure rather than a count: an item reporting its own progress
+ stands part of the way through, and a rate taken from whole items alone would hold still for
+ as long as one takes to finish.
+ """
+
+ def __init__(
+ self,
+ total: Union[int, float],
+ ems: float = ESTIMATION_MEASUREMENTS_SAMPLES,
+ ) -> None:
+ self._total = total
+ self._ems = self._get_estimation_measurements_samples(ems)
+ self._samples_window: Deque[Tuple[float, float]] = deque(maxlen=self._ems)
+ self._processed_items: float = 0.0
+
+ def update(self, completed_items: Union[int, float]) -> Optional[float]:
+ now = monotonic()
+ self._processed_items = completed_items
+ self._samples_window.append((now, completed_items))
+
+ if completed_items >= self._total:
+ return 0.0
+ if len(self._samples_window) < 2:
+ return None
+
+ return self._estimate_remaining_seconds(completed_items, now)
+
+ @classmethod
+ def format_duration(cls, seconds: Optional[float]) -> str:
+ if seconds is None:
+ return "?"
+ if seconds <= 0:
+ return "0s"
+
+ secs = int(seconds)
+ if secs <= 0:
+ return "0s"
+
+ minutes, seconds_remaining = divmod(secs, 60)
+ hours, minutes = divmod(minutes, 60)
+
+ if hours:
+ return f"{hours}h {minutes:02d}m {seconds_remaining:02d}s"
+ if minutes:
+ return f"{minutes}m {seconds_remaining:02d}s"
+
+ return f"{seconds_remaining}s"
+
+ def _get_estimation_measurements_samples(self, ems: float) -> int:
+ if isinstance(ems, float):
+ ems = round(ems * self._total)
+
+ return max(3, int(ems))
+
+ def _estimate_remaining_seconds(
+ self,
+ completed_items: float,
+ current_time: float,
+ ) -> Optional[float]:
+ if completed_items >= self._total:
+ return 0.0
+ if len(self._samples_window) < 2:
+ return None
+
+ first_time, first_completed = self._samples_window[0]
+ delta_completed = completed_items - first_completed
+ delta_time = current_time - first_time
+
+ if delta_time <= 0 or delta_completed <= 0:
+ return None
+
+ rate = delta_completed / delta_time
+ remaining = self._total - completed_items
+ return remaining / rate
diff --git a/src/sampletones_core/parallelization/steps.py b/src/sampletones_core/parallelization/steps.py
new file mode 100644
index 000000000..0b329e228
--- /dev/null
+++ b/src/sampletones_core/parallelization/steps.py
@@ -0,0 +1,48 @@
+import threading
+from typing import Dict, Set, Tuple
+
+from sampletones_core.parallelization.task import TaskReport, TaskStep
+
+
+class TaskSteps:
+ """Where each running task of a run stands, as the tasks themselves last said.
+
+ A task reports from wherever it runs while the run's monitor waits on the results the pool
+ hands back, so the two arrive on different threads and meet here. An entry lives from a task's
+ first report until the run counts that task as finished, which is what keeps a task's own
+ progress and the run's completed count from describing the same work twice.
+
+ A finished task stays finished. Its reports travel a line the run reads at its own pace, so one
+ filed before the result arrived may be read after it; the run has already counted that task
+ whole, and what it said on the way there is history by then.
+ """
+
+ def __init__(self) -> None:
+ self._steps: Dict[int, TaskStep] = {}
+ self._finished: Set[int] = set()
+ self._lock = threading.Lock()
+
+ def record(self, report: TaskReport) -> None:
+ """Takes the step a task filed as where that task now stands, while it is still running."""
+ with self._lock:
+ if report.index in self._finished:
+ return
+
+ self._steps[report.index] = report.step
+
+ def complete(self, index: int) -> None:
+ """Counts the task at ``index`` among the finished, whose work the run now carries itself."""
+ with self._lock:
+ self._finished.add(index)
+ self._steps.pop(index, None)
+
+ def clear(self) -> None:
+ """Lets go of every task, which is where a run about to start over stands."""
+ with self._lock:
+ self._steps.clear()
+ self._finished.clear()
+
+ def snapshot(self) -> Tuple[TaskStep, ...]:
+ """Where the running tasks stand, in the order the run handed them out."""
+ with self._lock:
+ return tuple(self._steps[index] for index in sorted(self._steps))
diff --git a/src/sampletones_core/parallelization/task.py b/src/sampletones_core/parallelization/task.py
index 6c7226610..14a0b0983 100644
--- a/src/sampletones_core/parallelization/task.py
+++ b/src/sampletones_core/parallelization/task.py
@@ -1,9 +1,9 @@
from enum import Enum
-from typing import Optional, TypeVar
+from typing import Final, Optional, Tuple
from pydantic import BaseModel, ConfigDict
-T = TypeVar("T")
+NOTHING_TO_DO: Final[int] = 0
class TaskStatus(Enum):
@@ -13,19 +13,55 @@ class TaskStatus(Enum):
COMPLETED = "COMPLETED"
FAILED = "FAILED"
CANCELLING = "CANCELLING"
- CANCELLED = "CANCELLED"
+ CANCELED = "CANCELED"
CLEANING_UP = "CLEANING_UP"
+class TaskStep(BaseModel):
+ """How far one task of a run has come through work only it can see.
+
+ A task that is the whole of what a run does leaves the run's own counts at nothing until it
+ finishes, so a task that knows its way through its work says so here. The stage names the unit
+ the counts are in, and the fraction is that stage read against the whole task, since the stages
+ a task passes through count in units of their own.
+ """
+
+ model_config = ConfigDict(frozen=True)
+
+ stage: str
+ completed: int
+ total: int
+ fraction: float
+
+
+class TaskReport(BaseModel):
+ """One task's step, and which task of the run it came from."""
+
+ model_config = ConfigDict(frozen=True)
+
+ index: int
+ step: TaskStep
+
+
class TaskProgress(BaseModel):
+ """How far a run has come: the tasks it finished, and where the ones still running stand."""
+
model_config = ConfigDict(frozen=True)
total: int
completed: int
current_item: Optional[str] = None
+ steps: Tuple[TaskStep, ...] = ()
+
+ @property
+ def partial(self) -> float:
+ """The work under way beyond the finished tasks, counted in tasks."""
+ return sum(step.fraction for step in self.steps)
- def get_progress(self) -> float:
- if self.total == 0:
+ @property
+ def fraction(self) -> float:
+ """How full the run stands, each running task counted for the part of it that is done."""
+ if self.total == NOTHING_TO_DO:
return 0.0
- return self.completed / self.total
+ return (self.completed + self.partial) / self.total
diff --git a/src/sampletones_core/performance/__init__.py b/src/sampletones_core/performance/__init__.py
new file mode 100644
index 000000000..68779f97e
--- /dev/null
+++ b/src/sampletones_core/performance/__init__.py
@@ -0,0 +1,26 @@
+from .audition import audition_audio
+from .modifiers import apply_modifiers
+from .progress import (
+ WalkProgress,
+ WalkReporter,
+ announce,
+)
+from .rows import apply_row, resolve_row
+from .song import song_instructions
+from .state import ChannelPerformance
+from .ticks import sound_tick
+from .voice import VoiceReading
+
+__all__ = [
+ "ChannelPerformance",
+ "VoiceReading",
+ "WalkProgress",
+ "WalkReporter",
+ "announce",
+ "apply_modifiers",
+ "apply_row",
+ "audition_audio",
+ "resolve_row",
+ "song_instructions",
+ "sound_tick",
+]
diff --git a/src/sampletones_core/performance/audition.py b/src/sampletones_core/performance/audition.py
new file mode 100644
index 000000000..4efff1eb9
--- /dev/null
+++ b/src/sampletones_core/performance/audition.py
@@ -0,0 +1,102 @@
+from typing import List, Optional
+
+import numpy as np
+
+from sampletones_core.configs import Config
+from sampletones_core.constants.enums import ChannelName
+from sampletones_core.constants.general import MAX_VOLUME
+from sampletones_core.features.envelope import releases
+from sampletones_core.generators.render import render_instructions
+from sampletones_core.instructions import InstructionUnion
+from sampletones_core.performance.modifiers import apply_modifiers
+from sampletones_core.project.voices.instrument import Instrument
+
+
+def audition_ticks(instrument: Instrument, *, cap: int) -> int:
+ """How long an audition of one voice sounds, in ticks.
+
+ A voice whose volume ends at silence releases itself, so it is sounded through to that release
+ and stops where a tracker row holding it would. One that circles from a loop point goes on for
+ as long as it is asked to, so it sounds for ``cap`` — the length an audition offers a voice
+ that never ends of its own accord.
+
+ Args:
+ instrument: The voice being sounded.
+ cap: The ticks a voice sounds for where it states no release of its own.
+
+ Returns:
+ int: The ticks the audition sounds.
+ """
+ volume = instrument.envelopes.volume
+ if releases(volume):
+ return len(volume.items)
+
+ return cap
+
+
+def audition_instructions(
+ instrument: Instrument,
+ channel_name: ChannelName,
+ *,
+ pitch: int,
+ ticks: int,
+) -> List[InstructionUnion]:
+ """The frames an instrument sounds on one channel at one note, at full volume.
+
+ An instrument's frames are built at the reference the channel reads, so sounding it at a note
+ is the step from that reference to the note — the same step a tracker row states, taken here
+ without a row to state it. A voice sounds past the frames it writes the way a held row sounds
+ it: each dimension circles from its own loop point or holds its last item.
+
+ Args:
+ instrument: The voice being sounded.
+ channel_name: The channel it is sounded on.
+ pitch: The note it sounds at, read as a period on the noise channel.
+ ticks: How many ticks to sound, as :func:`audition_ticks` counts them.
+
+ Returns:
+ List[InstructionUnion]: The frames, empty where the instrument writes no envelope.
+ """
+ if not instrument.instructions(channel_name):
+ return []
+
+ transpose = pitch - instrument.reference(channel_name)
+ return [
+ apply_modifiers(instrument.instruction_at(channel_name, tick), transpose, MAX_VOLUME) for tick in range(ticks)
+ ]
+
+
+def audition_audio(
+ instrument: Instrument,
+ channel_name: ChannelName,
+ config: Config,
+ *,
+ pitch: int,
+ ticks: int,
+) -> Optional[np.ndarray]:
+ """The audio an instrument sounds on one channel at one note.
+
+ This is what an audition plays and what a plot of a hand-written voice draws, so both answer
+ the same generator with the same waveform over the same span.
+
+ Args:
+ instrument: The voice being sounded.
+ channel_name: The channel it is sounded on.
+ config: The configuration the frames are rendered at.
+ pitch: The note it sounds at, read as a period on the noise channel.
+ ticks: How many ticks to sound, as :func:`audition_ticks` counts them.
+
+ Returns:
+ Optional[np.ndarray]: The waveform to play, or ``None`` where the instrument
+ writes no envelope for that channel.
+ """
+ instructions = audition_instructions(
+ instrument,
+ channel_name,
+ pitch=pitch,
+ ticks=ticks,
+ )
+ if not instructions:
+ return None
+
+ return render_instructions(instructions, channel_name, config)
diff --git a/src/sampletones_application/logic/sequencer/playback/synthesizer/modifiers.py b/src/sampletones_core/performance/modifiers.py
similarity index 83%
rename from src/sampletones_application/logic/sequencer/playback/synthesizer/modifiers.py
rename to src/sampletones_core/performance/modifiers.py
index 52a88b7e4..e4f7a9d1a 100644
--- a/src/sampletones_application/logic/sequencer/playback/synthesizer/modifiers.py
+++ b/src/sampletones_core/performance/modifiers.py
@@ -1,10 +1,11 @@
-from sampletones_core.constants.general import MAX_PITCH, MAX_VOLUME, MIN_PITCH
+from sampletones_core.constants.general import MAX_VOLUME
from sampletones_core.instructions import (
InstructionUnion,
NoiseInstruction,
PulseInstruction,
TriangleInstruction,
)
+from sampletones_core.utils.frequencies import transpose_period, transpose_pitch
def apply_modifiers(
@@ -31,13 +32,13 @@ def apply_modifiers(
match instruction:
case PulseInstruction():
scaled_volume = max(0, min(MAX_VOLUME, round(instruction.volume * row_volume / MAX_VOLUME)))
- effective_pitch = max(MIN_PITCH, min(MAX_PITCH, instruction.pitch + transpose))
+ effective_pitch = transpose_pitch(instruction.pitch, transpose)
return instruction.model_copy(update={"pitch": effective_pitch, "volume": scaled_volume})
case TriangleInstruction():
- effective_pitch = max(MIN_PITCH, min(MAX_PITCH, instruction.pitch + transpose))
+ effective_pitch = transpose_pitch(instruction.pitch, transpose)
on = instruction.on and row_volume > MAX_VOLUME // 2
return instruction.model_copy(update={"pitch": effective_pitch, "on": on})
case NoiseInstruction():
scaled_volume = max(0, min(MAX_VOLUME, round(instruction.volume * row_volume / MAX_VOLUME)))
- effective_period = (instruction.period + transpose) % 16
+ effective_period = transpose_period(instruction.period, transpose)
return instruction.model_copy(update={"period": effective_period, "volume": scaled_volume})
diff --git a/src/sampletones_core/performance/progress.py b/src/sampletones_core/performance/progress.py
new file mode 100644
index 000000000..ecd5ed589
--- /dev/null
+++ b/src/sampletones_core/performance/progress.py
@@ -0,0 +1,36 @@
+from dataclasses import dataclass
+from typing import Callable
+
+from sampletones_shared.exceptions import OperationCanceled
+
+
+@dataclass(frozen=True)
+class WalkProgress:
+ """How far a walk through a song's order has sounded it.
+
+ Attributes:
+ ticks: The engine ticks every channel has sounded so far.
+ total: The engine ticks the whole order lasts, which the project's groove states before
+ a single row is played.
+ """
+
+ ticks: int
+ total: int
+
+
+WalkReporter = Callable[[WalkProgress], bool]
+
+
+def announce(report: WalkReporter, ticks: int, total: int) -> None:
+ """Tells a reporter how far the walk has come, and unwinds a walk it withdraws.
+
+ Args:
+ report: Hears the walk and answers whether it goes on.
+ ticks: The engine ticks sounded so far.
+ total: The engine ticks the whole order lasts.
+
+ Raises:
+ OperationCanceled: If the walk is no longer wanted.
+ """
+ if not report(WalkProgress(ticks=ticks, total=total)):
+ raise OperationCanceled(f"the walk was withdrawn having sounded {ticks} of {total} ticks")
diff --git a/src/sampletones_core/performance/rows.py b/src/sampletones_core/performance/rows.py
new file mode 100644
index 000000000..b6956aa7c
--- /dev/null
+++ b/src/sampletones_core/performance/rows.py
@@ -0,0 +1,79 @@
+from typing import Optional
+
+from sampletones_core.constants.enums import ChannelName
+from sampletones_core.constants.general import MAX_VOLUME
+from sampletones_core.performance.state import ChannelPerformance
+from sampletones_core.project.patterns.row import Row
+from sampletones_core.project.song import Song
+from sampletones_core.project.song_position import SongPosition
+from sampletones_core.project.voices.note_off import NoteOff
+from sampletones_core.project.voices.note_on import NoteOn
+
+
+def resolve_row(
+ song: Song,
+ position: SongPosition,
+ channel_name: ChannelName,
+) -> Optional[Row]:
+ """The row one channel reaches at a position in the order.
+
+ A position answers with a row where the order plays a pattern on that channel and the
+ pattern is long enough to hold the row; anywhere else the channel plays on with whatever
+ it already carries.
+
+ Args:
+ song: The arrangement being played.
+ position: The order frame and the row within it.
+ channel_name: The channel whose pattern is read.
+
+ Returns:
+ Optional[Row]: The row the channel reaches, or ``None`` where it reaches none.
+ """
+ if position.order_position >= song.order_length():
+ return None
+
+ order_entry = song.order[position.order_position].get(channel_name)
+ if order_entry is None:
+ return None
+
+ pattern = song.pattern(channel_name, order_entry)
+ if pattern is None or position.row_index >= len(pattern.rows):
+ return None
+
+ return pattern.rows[position.row_index]
+
+
+def apply_row(performance: ChannelPerformance, row: Row) -> bool:
+ """Moves a channel onto the row it has reached, and reports whether the note starts over.
+
+ A note column names the voice to sound and begins it, taking the transpose and volume the row
+ states or the defaults where it states neither. A row naming no note leaves the voice playing
+ and changes only the columns it fills in, which is how a transpose or a volume bends a note
+ already sounding.
+
+ Args:
+ performance: What the channel carries; updated in place.
+ row: The row the channel reached.
+
+ Returns:
+ bool: Whether the channel starts over, which is where a phase-continuous voice resets.
+ """
+ match row.command:
+ case NoteOn() as note_on:
+ performance.voice_id = note_on.voice_id
+ performance.tick_index = 0
+ performance.transpose = row.transpose if row.transpose is not None else 0
+ performance.volume = row.volume if row.volume is not None else MAX_VOLUME
+ return True
+ case NoteOff():
+ performance.voice_id = None
+ performance.tick_index = 0
+ return True
+ case None:
+ if row.transpose is not None:
+ performance.transpose = row.transpose
+
+ if row.volume is not None:
+ performance.volume = row.volume
+
+ return False
diff --git a/src/sampletones_core/performance/song.py b/src/sampletones_core/performance/song.py
new file mode 100644
index 000000000..d1ba39fa1
--- /dev/null
+++ b/src/sampletones_core/performance/song.py
@@ -0,0 +1,108 @@
+from typing import Dict, List, Optional
+
+from sampletones_core.constants.enums import ChannelName
+from sampletones_core.exporters.maps import CHANNEL_TO_EXPORTER_MAP
+from sampletones_core.instructions import InstructionUnion
+from sampletones_core.performance.progress import (
+ WalkReporter,
+ announce,
+)
+from sampletones_core.performance.rows import apply_row, resolve_row
+from sampletones_core.performance.state import ChannelPerformance
+from sampletones_core.performance.ticks import sound_tick
+from sampletones_core.performance.voice import VoiceReading
+from sampletones_core.project.project import Project
+from sampletones_core.project.song_position import SongPosition
+from sampletones_core.project.voices.voice import VoiceUnion
+from sampletones_core.timing.song import SongTiming
+from sampletones_shared.utils.progress import silent_reporter
+
+
+def song_instructions(
+ project: Project,
+ report: WalkReporter = silent_reporter,
+) -> Dict[ChannelName, List[InstructionUnion]]:
+ """Plays a whole song out as the instructions each channel sounds, one per engine tick.
+
+ The order is walked frame by frame and row by row, each row lasting the ticks the project's
+ groove gives its position within the pattern. Every channel answers for each of those ticks,
+ so the four streams share one length and a tick's index into them is the same moment of the
+ song — which is what an engine consuming one instruction per tick plays from.
+
+ The groove states the ticks the whole order lasts before a row is played, so a walk of a song
+ of minutes says how far along it is and answers a caller who no longer wants it.
+
+ Args:
+ project: The project whose song is played.
+ report: Hears how far the walk has sounded, and answers whether it goes on.
+
+ Returns:
+ Dict[ChannelName, List[InstructionUnion]]: Each channel's stream, tick by tick.
+
+ Raises:
+ OperationCanceled: If ``report`` withdraws the walk.
+ """
+ song = project.song
+ groove = SongTiming.from_project(project).groove()
+ total = groove.total_ticks * song.order_length()
+ performances = {channel_name: ChannelPerformance() for channel_name in ChannelName.items()}
+ streams: Dict[ChannelName, List[InstructionUnion]] = {channel_name: [] for channel_name in ChannelName.items()}
+
+ position = SongPosition()
+ walked = 0
+ while position.order_position < song.order_length():
+ ticks = groove.ticks[position.row_index]
+ for channel_name in ChannelName.items():
+ performance = performances[channel_name]
+ row = resolve_row(song, position, channel_name)
+ if row is not None:
+ apply_row(performance, row)
+
+ streams[channel_name].extend(
+ _channel_ticks(
+ project.voice(performance.voice_id) if performance.voice_id is not None else None,
+ channel_name,
+ performance,
+ ticks,
+ )
+ )
+
+ walked += ticks
+ announce(report, walked, total)
+ position.advance(song.rows_per_pattern, song.order_length())
+
+ return streams
+
+
+def _channel_ticks(
+ voice: Optional[VoiceUnion],
+ channel_name: ChannelName,
+ performance: ChannelPerformance,
+ ticks: int,
+) -> List[InstructionUnion]:
+ """One channel's instructions across a single row.
+
+ A channel with nothing to sound rests for the whole row and keeps the tick it had reached,
+ so a voice removed from the project leaves the rows that named it silent while the rows
+ around them play on.
+
+ Args:
+ voice: The voice the channel is sounding, or ``None`` while it rests.
+ channel_name: The channel being sounded.
+ performance: What the channel carries; its tick index moves on per sounded tick.
+ ticks: The engine ticks the row lasts.
+
+ Returns:
+ List[InstructionUnion]: One instruction per tick of the row.
+ """
+ resting: InstructionUnion = CHANNEL_TO_EXPORTER_MAP[channel_name].get_instruction_type().null_instruction()
+ reading = VoiceReading.read(voice, channel_name) if voice is not None else None
+ if reading is None:
+ return [resting] * ticks
+
+ sounded: List[InstructionUnion] = []
+ for _ in range(ticks):
+ instruction = sound_tick(performance, reading)
+ sounded.append(resting if instruction is None else instruction)
+
+ return sounded
diff --git a/src/sampletones_core/performance/state.py b/src/sampletones_core/performance/state.py
new file mode 100644
index 000000000..a7930074c
--- /dev/null
+++ b/src/sampletones_core/performance/state.py
@@ -0,0 +1,46 @@
+from dataclasses import dataclass, field
+from typing import Dict, Optional
+
+from sampletones_core.constants.enums import FeatureKey
+from sampletones_core.constants.general import MAX_VOLUME
+from sampletones_core.features import CHANNEL_FEATURE_DEFAULTS
+
+
+@dataclass
+class ChannelPerformance:
+ """What one channel carries from row to row while a song plays.
+
+ A pattern states a channel's voice, transpose, and volume only where it changes them, so the
+ channel keeps the last of each until another row states otherwise. The tick index is how far
+ into the sounding voice's instructions the channel has played, which is what lets a note
+ sustain across rows.
+
+ The channel carries a value per envelope dimension too, which is what an instrument leaving a
+ dimension to the channel sounds at. A frame the instrument writes hands its value over, so the
+ channel keeps the last one written for as long as the song runs.
+
+ Attributes:
+ voice_id: The voice the channel is sounding, or ``None`` while it is silent.
+ tick_index: How many ticks of that voice's instructions the channel has played.
+ transpose: The semitone offset a row last set.
+ volume: The level a row last set.
+ feature_values: The value the channel holds for each envelope dimension.
+ """
+
+ voice_id: Optional[str] = field(default=None)
+ tick_index: int = field(default=0)
+ transpose: int = field(default=0)
+ volume: int = field(default=MAX_VOLUME)
+ feature_values: Dict[FeatureKey, int] = field(default_factory=CHANNEL_FEATURE_DEFAULTS.copy)
+
+ def reset(self) -> None:
+ """Returns the channel to silence at full volume, as a song starts it.
+
+ The envelope dimensions return to the values a channel holds from the start of a song,
+ so a pass through the song sounds the same however the previous one left them.
+ """
+ self.voice_id = None
+ self.tick_index = 0
+ self.transpose = 0
+ self.volume = MAX_VOLUME
+ self.feature_values = CHANNEL_FEATURE_DEFAULTS.copy()
diff --git a/src/sampletones_core/performance/ticks.py b/src/sampletones_core/performance/ticks.py
new file mode 100644
index 000000000..c4a6ced30
--- /dev/null
+++ b/src/sampletones_core/performance/ticks.py
@@ -0,0 +1,37 @@
+from typing import Optional
+
+from sampletones_core.instructions import InstructionUnion
+from sampletones_core.performance.modifiers import apply_modifiers
+from sampletones_core.performance.state import ChannelPerformance
+from sampletones_core.performance.voice import VoiceReading
+
+
+def sound_tick(
+ performance: ChannelPerformance,
+ reading: VoiceReading,
+) -> Optional[InstructionUnion]:
+ """The instruction a channel sounds this tick, and the step onto the next one.
+
+ The channel moves on a tick whatever the reading answers, so a voice that has run out keeps
+ counting and a row starting a note lands it back at the beginning.
+
+ Args:
+ performance: What the channel carries; its tick index moves on.
+ reading: How this channel reads the sounding voice.
+
+ Returns:
+ Optional[InstructionUnion]: The instruction to sound, or ``None`` where the voice has
+ played out and the channel rests.
+ """
+ index = performance.tick_index
+ performance.tick_index += 1
+
+ instruction = reading.at(index)
+ if instruction is None:
+ return None
+
+ return apply_modifiers(
+ reading.sound(instruction, performance.feature_values),
+ performance.transpose,
+ performance.volume,
+ )
diff --git a/src/sampletones_core/performance/voice.py b/src/sampletones_core/performance/voice.py
new file mode 100644
index 000000000..129bb1feb
--- /dev/null
+++ b/src/sampletones_core/performance/voice.py
@@ -0,0 +1,141 @@
+from __future__ import annotations
+
+from dataclasses import dataclass
+from typing import Dict, Optional, Sequence, Tuple
+
+from sampletones_core.constants.enums import ChannelName, FeatureKey
+from sampletones_core.exporters import CHANNEL_TO_EXPORTER_MAP, ExporterTypeUnion
+from sampletones_core.instructions import InstructionUnion
+from sampletones_core.project.voices.instrument import Instrument
+from sampletones_core.project.voices.sample import Sample
+from sampletones_core.project.voices.voice import VoiceUnion, voice_reference
+
+
+@dataclass(frozen=True)
+class VoiceReading:
+ """How one channel reads a voice's frames.
+
+ A voice carries a frame per tick stating every dimension the channel reads, and names which of
+ those dimensions it writes itself. The rest are the channel's own: the voice leaves an empty
+ envelope for them and the channel sounds them at the value it holds, which is what clearing an
+ envelope in the instruments panel means once the voice is played in a song.
+
+ Reading a voice on a channel answers all a channel needs of it — the frames, the reference its
+ arpeggio is measured against, the dimensions it leaves behind, and what it sounds once the
+ written frames run out — so the song walk and the sequencer's renderer read one voice the same
+ way.
+
+ Attributes:
+ exporter: The reading that turns this channel's frames into envelope values and back.
+ instructions: The frames the channel plays, one per tick.
+ reference: The pitch the arpeggio values are measured against.
+ held_features: The dimensions the voice leaves to the channel.
+ channel_name: The channel doing the reading.
+ sustaining: The instrument whose envelopes go on past the written frames, where one does.
+ """
+
+ exporter: ExporterTypeUnion
+ instructions: Sequence[InstructionUnion]
+ reference: int
+ held_features: Tuple[FeatureKey, ...]
+ channel_name: ChannelName
+ sustaining: Optional[Instrument]
+
+ @classmethod
+ def read(
+ cls,
+ voice: VoiceUnion,
+ channel_name: ChannelName,
+ ) -> Optional[VoiceReading]:
+ """The reading one channel plays ``voice`` through.
+
+ A sample answers with the frames its reconstruction found for this channel and the
+ reference they were measured against; an instrument answers with the frames its envelopes
+ make of this channel and the pitch it states. Both kinds therefore reach a channel as one
+ reading.
+
+ Args:
+ voice: The voice being sounded.
+ channel_name: The channel sounding it.
+
+ Returns:
+ Optional[VoiceReading]: The reading of that channel's frames, or ``None`` where the
+ voice describes no frame there and the channel rests.
+ """
+ sustaining: Optional[Instrument] = None
+ match voice:
+ case Sample():
+ instructions: Sequence[InstructionUnion] = voice.reconstruction.instructions[channel_name]
+ held_features = voice.reconstruction.held_features[channel_name]
+ case Instrument():
+ instructions = voice.instructions(channel_name)
+ held_features = voice.held_features(channel_name)
+ sustaining = voice
+
+ if not instructions:
+ return None
+
+ return cls(
+ exporter=CHANNEL_TO_EXPORTER_MAP[channel_name],
+ instructions=instructions,
+ reference=voice_reference(voice, channel_name),
+ held_features=held_features,
+ channel_name=channel_name,
+ sustaining=sustaining,
+ )
+
+ def at(self, tick_index: int) -> Optional[InstructionUnion]:
+ """The frame standing at ``tick_index`` of a sounding note.
+
+ An instrument goes on past its written frames: each dimension circles from its own loop
+ point or holds its last item, so a note sounds for as long as rows keep it sounding and a
+ volume envelope ending at silence is what releases it. A recording plays the frames its
+ conversion found, and the channel rests once they run out.
+
+ Args:
+ tick_index: How many ticks of the voice the channel has played.
+
+ Returns:
+ Optional[InstructionUnion]: The frame to sound, or ``None`` where the voice has played
+ out and the channel rests.
+ """
+ if tick_index < len(self.instructions):
+ return self.instructions[tick_index]
+
+ if self.sustaining is not None:
+ return self.sustaining.instruction_at(self.channel_name, tick_index)
+
+ return None
+
+ def sound(
+ self,
+ instruction: InstructionUnion,
+ feature_values: Dict[FeatureKey, int],
+ ) -> InstructionUnion:
+ """The frame the channel sounds, once the dimensions it governs are filled in.
+
+ ``feature_values`` is the channel's own, and this is where it moves: the dimensions the
+ frame states and the voice writes are handed over to it, and every dimension the frame
+ plays is then read back out of it. So a voice that writes a dimension sets what the channel
+ holds, and one that leaves it empty sounds at what the channel holds.
+
+ Args:
+ instruction: The frame as the voice holds it.
+ feature_values: The values the channel holds, updated with what the voice writes.
+
+ Returns:
+ InstructionUnion: The frame to sound, before the pattern's transpose and volume.
+ """
+ stated = self.exporter.feature_values(
+ instruction, # type: ignore[arg-type]
+ self.reference,
+ )
+ for feature_key, value in stated.items():
+ if feature_key not in self.held_features:
+ feature_values[feature_key] = value
+
+ sounded: InstructionUnion = self.exporter.instruction_from_values(
+ feature_values,
+ self.reference,
+ )
+ return sounded
diff --git a/src/sampletones_core/project/__init__.py b/src/sampletones_core/project/__init__.py
index 5cf443901..9def964de 100644
--- a/src/sampletones_core/project/__init__.py
+++ b/src/sampletones_core/project/__init__.py
@@ -1,16 +1,16 @@
from .container import ProjectContainer
from .info import ProjectInfo
-from .instruments.instrument import Instrument
from .patterns.channel import Channel
from .patterns.pattern import Pattern
from .patterns.row import Row
from .project import Project
from .settings import ProjectSettings
from .song import Song
+from .voices.note_on import NoteOn
__all__ = [
"Channel",
- "Instrument",
+ "NoteOn",
"Pattern",
"Project",
"ProjectContainer",
diff --git a/src/sampletones_core/project/container.py b/src/sampletones_core/project/container.py
index 375b93e4b..d5555e70b 100644
--- a/src/sampletones_core/project/container.py
+++ b/src/sampletones_core/project/container.py
@@ -4,10 +4,14 @@
from pydantic import ValidationError
+from sampletones_core.compatibility.kind import ObjectKind
+from sampletones_core.compatibility.upgrade import upgrade_json
from sampletones_core.project.document import ProjectDocument
-from sampletones_core.project.instruments.record import SampleRecord
-from sampletones_core.project.instruments.sample import Sample
from sampletones_core.project.project import Project
+from sampletones_core.project.voices.instrument import Instrument
+from sampletones_core.project.voices.record import SampleRecord, VoiceRecord
+from sampletones_core.project.voices.sample import Sample
+from sampletones_core.project.voices.voice import VoiceUnion
from sampletones_core.reconstructions import Reconstruction
from sampletones_core.structures import IdentifiedCollection
from sampletones_shared.application import SAMPLETONES_PROJECT_DATA_VERSION
@@ -37,9 +41,9 @@ class ProjectContainer:
The archive (``.stp``) is a zip holding a single ``project.json`` -- the
validated :class:`ProjectDocument` -- plus one ``reconstructions/.stn`` per
- unique reconstruction in its existing binary format. Samples embed
- reconstructions in memory but reference them by ``reconstruction_id`` on disk,
- so a reconstruction shared by several samples is stored exactly once.
+ unique reconstruction in its existing binary format. A sample embeds its
+ reconstruction in memory but references it by ``reconstruction_id`` on disk,
+ so a reconstruction shared by several voices is stored exactly once.
The entire JSON shape lives in :class:`ProjectDocument`; this class only maps
the domain to and from it and manages the reconstruction archive. It is a
@@ -63,7 +67,12 @@ def save(project: Project, path: Pathlike) -> None:
def load(path: Pathlike) -> Project:
try:
with zipfile.ZipFile(path, "r") as archive:
- document = ProjectDocument.model_validate_json(archive.read(PROJECT_DOCUMENT_NAME))
+ document = ProjectDocument.model_validate_json(
+ upgrade_json(
+ ObjectKind.PROJECT,
+ archive.read(PROJECT_DOCUMENT_NAME),
+ )
+ )
ProjectContainer._validate_document(document)
reconstructions = ProjectContainer._read_reconstructions(archive)
return ProjectContainer._build_project(document, reconstructions)
@@ -108,14 +117,7 @@ def _build_document(project: Project) -> ProjectDocument:
metadata=project.metadata,
info=project.info,
settings=project.settings,
- samples=[
- SampleRecord(
- id=sample.id,
- name=sample.name,
- reconstruction_id=sample.reconstruction.id,
- )
- for sample in project.samples
- ],
+ voices=[ProjectContainer._voice_record(voice) for voice in project.voices],
song=project.song,
)
@@ -124,30 +126,58 @@ def _build_project(
document: ProjectDocument,
reconstructions: Dict[str, Reconstruction],
) -> Project:
- samples: IdentifiedCollection[Sample] = IdentifiedCollection()
- for record in document.samples:
- reconstruction = reconstructions[record.reconstruction_id]
- samples.append(ProjectContainer._restore_sample(record, reconstruction))
+ voices: IdentifiedCollection[VoiceUnion] = IdentifiedCollection()
+ for record in document.voices:
+ voices.append(ProjectContainer._restore_voice(record, reconstructions))
return Project(
metadata=document.metadata,
info=document.info,
settings=document.settings,
- samples=samples,
+ voices=voices,
song=document.song,
)
@staticmethod
- def _restore_sample(record: SampleRecord, reconstruction: Reconstruction) -> Sample:
- sample = Sample(name=record.name, reconstruction=reconstruction)
- sample.id = record.id
- return sample
+ def _voice_record(voice: VoiceUnion) -> VoiceRecord:
+ """The record a voice is written as: a reference for a sample, the whole of it for an instrument."""
+ match voice:
+ case Sample():
+ return SampleRecord(
+ id=voice.id,
+ name=voice.name,
+ reconstruction_id=voice.reconstruction.id,
+ )
+ case Instrument():
+ return voice
+
+ @staticmethod
+ def _restore_voice(
+ record: VoiceRecord,
+ reconstructions: Dict[str, Reconstruction],
+ ) -> VoiceUnion:
+ """The voice a record describes, resolving a sample's reconstruction from the archive.
+
+ Raises:
+ KeyError: If a sample record names a reconstruction the archive holds none of.
+ """
+ match record:
+ case SampleRecord():
+ sample = Sample(
+ name=record.name,
+ reconstruction=reconstructions[record.reconstruction_id],
+ )
+ sample.id = record.id
+ return sample
+ case Instrument():
+ return record
@staticmethod
def _unique_reconstructions(project: Project) -> Dict[str, Reconstruction]:
reconstructions: Dict[str, Reconstruction] = {}
- for sample in project.samples:
- reconstructions[sample.reconstruction.id] = sample.reconstruction
+ for voice in project.voices:
+ if isinstance(voice, Sample):
+ reconstructions[voice.reconstruction.id] = voice.reconstruction
return reconstructions
diff --git a/src/sampletones_core/project/document.py b/src/sampletones_core/project/document.py
index 63dfcc0b3..d13c00b9b 100644
--- a/src/sampletones_core/project/document.py
+++ b/src/sampletones_core/project/document.py
@@ -3,7 +3,7 @@
from pydantic import BaseModel, ConfigDict, Field
from sampletones_core.data import Metadata
-from sampletones_core.project.instruments.record import SampleRecord
+from sampletones_core.project.voices.record import VoiceRecord
from sampletones_shared.application import SAMPLETONES_PROJECT_DATA_VERSION
from .info import ProjectInfo
@@ -14,8 +14,8 @@
class ProjectDocument(BaseModel):
"""The single, validated schema for a project's ``project.json``.
- It embeds the domain :class:`Song` and represents samples as lightweight records,
- since their reconstructions live as separate ``.stn`` members of the archive.
+ It embeds the domain :class:`Song` and represents each voice as a lightweight record,
+ since a sample's reconstruction lives as a separate ``.stn`` member of the archive.
``extra="ignore"`` lets it accept older or unknown fields, and ``format_version``
carries the schema version that drives upgrades.
"""
@@ -30,5 +30,5 @@ class ProjectDocument(BaseModel):
metadata: Metadata
info: ProjectInfo
settings: ProjectSettings
- samples: List[SampleRecord]
+ voices: List[VoiceRecord]
song: Song
diff --git a/src/sampletones_core/project/instruments/__init__.py b/src/sampletones_core/project/instruments/__init__.py
deleted file mode 100644
index 576c12dee..000000000
--- a/src/sampletones_core/project/instruments/__init__.py
+++ /dev/null
@@ -1,9 +0,0 @@
-from .instrument import Instrument
-from .record import SampleRecord
-from .sample import Sample
-
-__all__ = [
- "Instrument",
- "Sample",
- "SampleRecord",
-]
diff --git a/src/sampletones_core/project/instruments/instrument.py b/src/sampletones_core/project/instruments/instrument.py
deleted file mode 100644
index 3156c441e..000000000
--- a/src/sampletones_core/project/instruments/instrument.py
+++ /dev/null
@@ -1,21 +0,0 @@
-from pydantic import BaseModel, ConfigDict, Field
-
-from sampletones_core.constants.enums import GeneratorName
-
-
-class Instrument(BaseModel):
- """A reference to a single NES-channel slice of a sample's reconstruction.
-
- A reconstruction may span up to four channels (two pulse, triangle, noise).
- A subinstrument pins one of those channels for use on a tracker row. The
- sample is referenced by its stable ``id``, so the reference survives
- reordering of the samples collection.
- """
-
- model_config = ConfigDict(frozen=True)
-
- sample_id: str = Field(..., description="Stable id of the referenced sample.")
- generator_name: GeneratorName = Field(
- ...,
- description="Which reconstruction channel-slice to use.",
- )
diff --git a/src/sampletones_core/project/patterns/channel.py b/src/sampletones_core/project/patterns/channel.py
index eb4f81df2..af76fbcd1 100644
--- a/src/sampletones_core/project/patterns/channel.py
+++ b/src/sampletones_core/project/patterns/channel.py
@@ -4,7 +4,7 @@
from pydantic import BaseModel, Field
-from sampletones_core.constants.enums import GeneratorName
+from sampletones_core.constants.enums import ChannelName
from sampletones_core.project.patterns.pattern import Pattern
from sampletones_core.project.patterns.row import Row
@@ -21,12 +21,12 @@ class Channel(BaseModel):
position and how many positions exist.
"""
- generator: GeneratorName = Field(..., description="The NES channel this pool drives.")
+ name: ChannelName = Field(..., description="The NES channel this pool drives.")
patterns: Dict[int, Pattern] = Field(..., description="Pattern pool keyed by index.")
@classmethod
- def empty(cls, generator: GeneratorName, rows_per_pattern: int) -> Channel:
- return cls(generator=generator, patterns={0: Pattern.empty(rows_per_pattern)})
+ def empty(cls, name: ChannelName, rows_per_pattern: int) -> Channel:
+ return cls(name=name, patterns={0: Pattern.empty(rows_per_pattern)})
def pattern(self, index: int) -> Optional[Pattern]:
return self.patterns.get(index)
@@ -84,4 +84,4 @@ def set_row(self, index: int, row_index: int, row: Row) -> None:
self.patterns[index].rows[row_index] = row
def __repr__(self) -> str:
- return f"Channel(generator={self.generator}, patterns={len(self.patterns)})"
+ return f"Channel(name={self.name}, patterns={len(self.patterns)})"
diff --git a/src/sampletones_core/project/patterns/row.py b/src/sampletones_core/project/patterns/row.py
index 54af3b1ee..96aae1158 100644
--- a/src/sampletones_core/project/patterns/row.py
+++ b/src/sampletones_core/project/patterns/row.py
@@ -8,16 +8,16 @@
MIN_TRANSPOSE,
SILENT_VOLUME,
)
-from sampletones_core.project.instruments.instrument import Instrument
-from sampletones_core.project.instruments.note_off import NoteOff
+from sampletones_core.project.voices.note_off import NoteOff
+from sampletones_core.project.voices.note_on import NoteOn
-NoteCommand = Union[Instrument, NoteOff]
+NoteCommand = Union[NoteOn, NoteOff]
class Row(BaseModel):
"""A single tracker line on one channel.
- The note column holds a :data:`NoteCommand`: an :class:`Instrument` reference, a
+ The note column holds a :data:`NoteCommand`: a :class:`NoteOn` naming the voice to start, a
:class:`NoteOff`, or ``None`` for an empty cell. Transpose and volume are independent optional
columns. A fully empty row (no command, no transpose, no volume) is a blank line.
"""
@@ -26,13 +26,13 @@ class Row(BaseModel):
command: Optional[NoteCommand] = Field(
default=None,
- description="Note-column command: a sample reference, a note-off, or None for an empty cell.",
+ description="Note-column command: a voice reference, a note-off, or None for an empty cell.",
)
transpose: Optional[int] = Field(
default=None,
ge=MIN_TRANSPOSE,
le=MAX_TRANSPOSE,
- description="Note pitch, or None for an empty cell.",
+ description="Semitones from the voice's reference pitch, or None for an empty cell.",
)
volume: Optional[int] = Field(
default=None,
@@ -44,6 +44,6 @@ class Row(BaseModel):
def is_empty(self) -> bool:
return self.command is None and self.transpose is None and self.volume is None
- def references_sample(self, sample_id: str) -> bool:
+ def references_voice(self, voice_id: str) -> bool:
command = self.command
- return isinstance(command, Instrument) and command.sample_id == sample_id
+ return isinstance(command, NoteOn) and command.voice_id == voice_id
diff --git a/src/sampletones_core/project/project.py b/src/sampletones_core/project/project.py
index 531b8a00a..a277691d5 100644
--- a/src/sampletones_core/project/project.py
+++ b/src/sampletones_core/project/project.py
@@ -3,7 +3,7 @@
from typing import Optional
from sampletones_core.data import Metadata
-from sampletones_core.project.instruments.sample import Sample
+from sampletones_core.project.voices.voice import VoiceUnion
from sampletones_core.structures import IdentifiedCollection
from sampletones_shared.constants.project import (
DEFAULT_PROJECT_AUTHOR,
@@ -20,10 +20,10 @@
class Project:
"""The top-level container for everything a user composes.
- Owns the samples (each embedding its own reconstruction) and the song
- arrangement. References inside the song point at samples by their stable
- ``id``; the :class:`IdentifiedCollection` resolves those ids in O(1) while
- also exposing reorder-safe positions for the UI.
+ Owns the voices — samples embedding their own reconstruction, and shapes carrying
+ their own envelopes — and the song arrangement. References inside the song point at
+ voices by their stable ``id``; the :class:`IdentifiedCollection` resolves those ids
+ in O(1) while also exposing reorder-safe positions for the UI.
"""
def __init__(
@@ -31,13 +31,13 @@ def __init__(
metadata: Metadata,
info: ProjectInfo,
settings: ProjectSettings,
- samples: IdentifiedCollection[Sample],
+ voices: IdentifiedCollection[VoiceUnion],
song: Song,
) -> None:
self.metadata: Metadata = metadata
self.info: ProjectInfo = info
self.settings: ProjectSettings = settings
- self.samples: IdentifiedCollection[Sample] = samples
+ self.voices: IdentifiedCollection[VoiceUnion] = voices
self.song: Song = song
@classmethod
@@ -62,12 +62,12 @@ def create(
metadata=Metadata.default(),
info=info,
settings=settings,
- samples=IdentifiedCollection(),
+ voices=IdentifiedCollection(),
song=Song.empty(rows_per_pattern),
)
- def sample(self, sample_id: str) -> Optional[Sample]:
- return self.samples.get(sample_id)
+ def voice(self, voice_id: str) -> Optional[VoiceUnion]:
+ return self.voices.get(voice_id)
def __repr__(self) -> str:
- return f"Project(title={self.info.title!r}, samples={len(self.samples)})"
+ return f"Project(title={self.info.title!r}, voices={len(self.voices)})"
diff --git a/src/sampletones_core/project/song.py b/src/sampletones_core/project/song.py
index f779f8d06..459a820fa 100644
--- a/src/sampletones_core/project/song.py
+++ b/src/sampletones_core/project/song.py
@@ -4,7 +4,7 @@
from pydantic import BaseModel, ConfigDict, Field
-from sampletones_core.constants.enums import GeneratorName
+from sampletones_core.constants.enums import ChannelName
from sampletones_core.project.patterns.channel import Channel
from sampletones_core.project.patterns.pattern import Pattern
from sampletones_core.project.patterns.row import Row
@@ -34,35 +34,35 @@ class Song(BaseModel):
le=MAX_ROWS_PER_PATTERN,
description="Row count enforced for every pattern.",
)
- order: List[Dict[GeneratorName, Optional[int]]] = Field(
+ order: List[Dict[ChannelName, Optional[int]]] = Field(
..., description="Arrangement: one frame per position, mapping each channel to a pattern index."
)
- channels: Dict[GeneratorName, Channel] = Field(..., description="Pattern pool per NES channel.")
+ channels: Dict[ChannelName, Channel] = Field(..., description="Pattern pool per NES channel.")
@classmethod
def empty(cls, rows_per_pattern: int) -> Song:
- channels = {generator: Channel.empty(generator, rows_per_pattern) for generator in GeneratorName.items()}
- first_frame: Dict[GeneratorName, Optional[int]] = {generator: 0 for generator in GeneratorName.items()}
+ channels = {channel: Channel.empty(channel, rows_per_pattern) for channel in ChannelName.items()}
+ first_frame: Dict[ChannelName, Optional[int]] = {channel: 0 for channel in ChannelName.items()}
return cls(rows_per_pattern=rows_per_pattern, order=[first_frame], channels=channels)
- def __getitem__(self, generator: GeneratorName) -> Channel:
- return self.channels[generator]
+ def __getitem__(self, channel: ChannelName) -> Channel:
+ return self.channels[channel]
- def pattern(self, generator: GeneratorName, index: int) -> Optional[Pattern]:
- return self.channels[generator].pattern(index)
+ def pattern(self, channel_name: ChannelName, index: int) -> Optional[Pattern]:
+ return self.channels[channel_name].pattern(index)
def order_length(self) -> int:
return len(self.order)
- def ordered_patterns(self, generator: GeneratorName) -> List[Optional[Pattern]]:
- channel = self.channels[generator]
+ def ordered_patterns(self, channel_name: ChannelName) -> List[Optional[Pattern]]:
+ channel = self.channels[channel_name]
return [
channel.patterns.get(index) if index is not None else None
- for index in (frame.get(generator) for frame in self.order)
+ for index in (frame.get(channel_name) for frame in self.order)
]
- def _empty_frame(self) -> Dict[GeneratorName, Optional[int]]:
- return {generator: None for generator in GeneratorName.items()}
+ def _empty_frame(self) -> Dict[ChannelName, Optional[int]]:
+ return {channel: None for channel in ChannelName.items()}
def append_frame(self) -> None:
self.order.append(self._empty_frame())
@@ -70,8 +70,8 @@ def append_frame(self) -> None:
def insert_frame(self, position: int) -> None:
self.order.insert(position, self._empty_frame())
- def set_order_entry(self, position: int, generator: GeneratorName, index: Optional[int]) -> None:
- self.order[position][generator] = index
+ def set_order_entry(self, position: int, channel: ChannelName, index: Optional[int]) -> None:
+ self.order[position][channel] = index
def remove_frame(self, position: int) -> None:
del self.order[position]
@@ -80,26 +80,26 @@ def move_frame(self, from_position: int, to_position: int) -> None:
frame = self.order.pop(from_position)
self.order.insert(to_position, frame)
- def add_pattern(self, generator: GeneratorName) -> int:
- """Adds an empty pattern to ``generator`` at a free index and returns it.
+ def add_pattern(self, channel: ChannelName) -> int:
+ """Adds an empty pattern to ``channel`` at a free index and returns it.
The index clears both the channel's pool and every index its order slots
already reference, so it never aliases a slot whose pattern is unmaterialised.
"""
- return self.channels[generator].add_pattern(
+ return self.channels[channel].add_pattern(
self.rows_per_pattern,
- reserved_indices=self._referenced_indices(generator),
+ reserved_indices=self._referenced_indices(channel),
)
- def clone_pattern(self, generator: GeneratorName, index: int) -> int:
- """Clones ``generator``'s pattern at ``index`` into a free index and returns it.
+ def clone_pattern(self, channel: ChannelName, index: int) -> int:
+ """Clones ``channel``'s pattern at ``index`` into a free index and returns it.
The clone index clears the channel's pool and every order-referenced index, so
the copy stays independent of any slot the order already plays.
"""
- return self.channels[generator].clone_pattern(
+ return self.channels[channel].clone_pattern(
index,
- reserved_indices=self._referenced_indices(generator),
+ reserved_indices=self._referenced_indices(channel),
)
def duplicate_frame(self, position: int) -> None:
@@ -121,46 +121,46 @@ def clone_frame(self, position: int) -> None:
the other unchanged. Silent slots stay silent.
"""
source_frame = self.order[position]
- clone: Dict[GeneratorName, Optional[int]] = {}
- for generator in GeneratorName.items():
- index = source_frame.get(generator)
+ clone: Dict[ChannelName, Optional[int]] = {}
+ for channel in ChannelName.items():
+ index = source_frame.get(channel)
if index is None:
- clone[generator] = None
+ clone[channel] = None
continue
- self.channels[generator].ensure_pattern(index, self.rows_per_pattern)
- clone[generator] = self.clone_pattern(generator, index)
+ self.channels[channel].ensure_pattern(index, self.rows_per_pattern)
+ clone[channel] = self.clone_pattern(channel, index)
self.order.insert(position + 1, clone)
- def _referenced_indices(self, generator: GeneratorName) -> Set[int]:
- return {index for frame in self.order if (index := frame.get(generator)) is not None}
+ def _referenced_indices(self, channel: ChannelName) -> Set[int]:
+ return {index for frame in self.order if (index := frame.get(channel)) is not None}
def clear_frame(self, position: int) -> None:
self.order[position] = self._empty_frame()
- def remove_pattern(self, generator: GeneratorName, index: int) -> None:
+ def remove_pattern(self, channel: ChannelName, index: int) -> None:
"""Removes a pattern from the pool and clears all order references to it."""
- self.channels[generator].remove_pattern(index)
+ self.channels[channel].remove_pattern(index)
for frame in self.order:
- if frame.get(generator) == index:
- frame[generator] = None
+ if frame.get(channel) == index:
+ frame[channel] = None
- def references_sample(self, sample_id: str) -> bool:
- """Whether any row in any pattern of any channel still points at the sample."""
+ def references_voice(self, voice_id: str) -> bool:
+ """Whether any row in any pattern of any channel still points at the voice."""
return any(
- row.references_sample(sample_id)
+ row.references_voice(voice_id)
for channel in self.channels.values()
for pattern in channel.patterns.values()
for row in pattern.rows
)
- def clear_sample_references(self, sample_id: str) -> None:
- """Clears the note-column command of every row that points at a removed sample."""
+ def clear_voice_references(self, voice_id: str) -> None:
+ """Clears the note-column command of every row that points at a removed voice."""
for channel in self.channels.values():
for pattern in channel.patterns.values():
pattern.rows = [
- row.model_copy(update={"command": None}) if row.references_sample(sample_id) else row
+ row.model_copy(update={"command": None}) if row.references_voice(voice_id) else row
for row in pattern.rows
]
diff --git a/src/sampletones_core/project/tuning.py b/src/sampletones_core/project/tuning.py
new file mode 100644
index 000000000..f961e4723
--- /dev/null
+++ b/src/sampletones_core/project/tuning.py
@@ -0,0 +1,40 @@
+from typing import Final, Set
+
+from sampletones_core.project.project import Project
+from sampletones_core.project.voices.voice import samples
+from sampletones_shared.music import Tuning
+
+UNTUNED_PROJECT: Final[Tuning] = Tuning()
+
+
+def _named(tuning: Tuning) -> str:
+ return f"A{tuning.a4_pitch} at {tuning.a4_frequency} Hz"
+
+
+def tuning_from_project(project: Project) -> Tuning:
+ """Where concert pitch sits for a whole project, which its samples state together.
+
+ A project carries no tuning of its own: each sample was reconstructed against one, and a
+ format sounding those pitches itself — the console player reaching them through timer values
+ — measures the whole song from a single tuning. The samples state it by agreeing on it, and
+ a project holding none takes the tuning a reconstruction is built against by default.
+
+ Args:
+ project: The project whose samples state the tuning.
+
+ Returns:
+ Tuning: The tuning every sample of the project was reconstructed against.
+
+ Raises:
+ ValueError: If the samples were reconstructed against tunings that differ, which one
+ timer table sounds only one of.
+ """
+ tunings: Set[Tuning] = {sample.reconstruction.config.tuning for sample in samples(project.voices)}
+ if not tunings:
+ return UNTUNED_PROJECT
+
+ if len(tunings) > 1:
+ stated = ", ".join(sorted(_named(tuning) for tuning in tunings))
+ raise ValueError(f"a project sounds one tuning, and its samples were reconstructed at {stated}")
+
+ return tunings.pop()
diff --git a/src/sampletones_core/project/voices/__init__.py b/src/sampletones_core/project/voices/__init__.py
new file mode 100644
index 000000000..4c18e4f8a
--- /dev/null
+++ b/src/sampletones_core/project/voices/__init__.py
@@ -0,0 +1,23 @@
+from .creation import new_instrument
+from .envelopes import InstrumentEnvelopes
+from .instrument import Instrument
+from .note_off import NoteOff
+from .note_on import NoteOn
+from .record import SampleRecord, VoiceRecord
+from .sample import Sample
+from .voice import VoiceUnion, samples, voice_channels, voice_reference
+
+__all__ = [
+ "Instrument",
+ "InstrumentEnvelopes",
+ "NoteOff",
+ "NoteOn",
+ "Sample",
+ "SampleRecord",
+ "VoiceRecord",
+ "VoiceUnion",
+ "new_instrument",
+ "samples",
+ "voice_channels",
+ "voice_reference",
+]
diff --git a/src/sampletones_core/project/voices/creation.py b/src/sampletones_core/project/voices/creation.py
new file mode 100644
index 000000000..4d23beecd
--- /dev/null
+++ b/src/sampletones_core/project/voices/creation.py
@@ -0,0 +1,88 @@
+from typing import Final
+
+from sampletones_core.constants.enums import ChannelName
+from sampletones_core.constants.general import MAX_VOLUME
+from sampletones_core.exporters.feature import Features
+from sampletones_core.features import (
+ RESTING_REFERENCE_PERIOD,
+ RESTING_REFERENCE_PITCH,
+ speaks_in_periods,
+)
+from sampletones_core.features.envelope import Envelope
+from sampletones_core.project.voices.envelopes import InstrumentEnvelopes
+from sampletones_core.project.voices.instrument import Instrument
+
+SUSTAINING_ENVELOPES: Final[InstrumentEnvelopes] = InstrumentEnvelopes(
+ volume=Envelope(items=(MAX_VOLUME,), loop_point=0),
+)
+
+
+def new_instrument(name: str) -> Instrument:
+ """An instrument a reader can place and hear straight away, before writing an envelope of its own.
+
+ An instrument sounds the frames its envelopes describe, so one holding a single full-volume tick
+ that repeats holds a note for as long as a row asks for it, at the pitch a channel added by
+ hand rests on. Arpeggio and duty cycle stay the channel's until the reader writes them.
+
+ Args:
+ name: The name the voice list shows.
+
+ Returns:
+ Instrument: A voice sustaining at full volume on every channel.
+ """
+ return Instrument(
+ name=name,
+ envelopes=SUSTAINING_ENVELOPES,
+ )
+
+
+def instrument_from_features(
+ name: str,
+ features: Features,
+ channel_name: ChannelName,
+) -> Instrument:
+ """An instrument carrying what one channel plays, in envelopes the reader can edit.
+
+ The envelopes come across as they stand, so the voice sounds on the channel it came from what
+ that channel sounded; a dimension the channel governs stays governed wherever the voice is
+ placed next. Each dimension holds its final value once it runs out, the way a recorded channel
+ rests on the value it last played. The channel's own reference becomes the initial pitch it was
+ measured against — a period on noise and a note elsewhere — and the one the other channels read
+ rests where a voice added by hand rests, so the voice stands somewhere sensible on all of them.
+
+ Args:
+ name: The name the voice list shows.
+ features: The envelopes the channel plays.
+ channel_name: The channel those envelopes were measured for.
+
+ Returns:
+ Instrument: The voice those envelopes describe.
+ """
+ return Instrument(
+ name=name,
+ envelopes=InstrumentEnvelopes(
+ volume=features.volume,
+ arpeggio=features.arpeggio,
+ pitch=features.pitch if features.pitch is not None else Envelope[int](),
+ hi_pitch=features.hi_pitch if features.hi_pitch is not None else Envelope[int](),
+ duty_cycle=features.duty_cycle if features.duty_cycle is not None else Envelope[int](),
+ ),
+ initial_pitch=_initial_pitch(channel_name, features.initial_pitch),
+ initial_period=_initial_period(channel_name, features.initial_pitch),
+ )
+
+
+def _initial_pitch(channel_name: ChannelName, reference: int) -> int:
+ """The note the tonal channels measure the arpeggio against, taken from ``reference`` where it is one."""
+ if speaks_in_periods(channel_name):
+ return RESTING_REFERENCE_PITCH
+
+ return reference
+
+
+def _initial_period(channel_name: ChannelName, reference: int) -> int:
+ """The period the noise channel measures the arpeggio against, taken from ``reference`` where it is one."""
+ if speaks_in_periods(channel_name):
+ return reference
+
+ return RESTING_REFERENCE_PERIOD
diff --git a/src/sampletones_core/project/voices/envelopes.py b/src/sampletones_core/project/voices/envelopes.py
new file mode 100644
index 000000000..8ef13feaa
--- /dev/null
+++ b/src/sampletones_core/project/voices/envelopes.py
@@ -0,0 +1,100 @@
+from __future__ import annotations
+
+from typing import Annotated, Dict
+
+from pydantic import BaseModel, ConfigDict, Field
+
+from sampletones_core.constants.enums import FeatureKey
+from sampletones_core.constants.general import (
+ ARPEGGIO_MAX,
+ ARPEGGIO_MIN,
+ MAX_DUTY_CYCLE,
+ MAX_VOLUME,
+ PITCH_BEND_MAX,
+ PITCH_BEND_MIN,
+ SILENT_VOLUME,
+)
+from sampletones_core.features.envelope import Envelope
+
+VolumeItem = Annotated[int, Field(ge=SILENT_VOLUME, le=MAX_VOLUME)]
+ArpeggioItem = Annotated[int, Field(ge=ARPEGGIO_MIN, le=ARPEGGIO_MAX)]
+PitchItem = Annotated[int, Field(ge=PITCH_BEND_MIN, le=PITCH_BEND_MAX)]
+HiPitchItem = Annotated[int, Field(ge=PITCH_BEND_MIN, le=PITCH_BEND_MAX)]
+DutyCycleItem = Annotated[int, Field(ge=0, le=MAX_DUTY_CYCLE)]
+
+
+class InstrumentEnvelopes(BaseModel):
+ """The per-tick envelopes an instrument writes, in the terms every channel reads them in.
+
+ Each dimension carries the widest range the four channels offer, and a channel takes what it
+ reads: an arpeggio item is a semitone offset on the tonal channels and a period offset on
+ noise, and a duty-cycle item selects a pulse waveform or the noise channel's short mode. An
+ empty envelope leaves that dimension to the channel, which keeps the value it already holds —
+ the same record a reconstruction's held dimensions carry.
+
+ Attributes:
+ volume: Output level per tick.
+ arpeggio: Offset from the instrument's initial pitch per tick.
+ pitch: Divider steps the note is bent by per tick, one step per unit.
+ hi_pitch: Divider steps the note is bent by per tick, sixteen steps per unit.
+ duty_cycle: Pulse waveform, or noise mode, per tick.
+ """
+
+ model_config = ConfigDict(frozen=True, extra="forbid")
+
+ volume: Envelope[VolumeItem] = Envelope[VolumeItem]()
+ arpeggio: Envelope[ArpeggioItem] = Envelope[ArpeggioItem]()
+ pitch: Envelope[PitchItem] = Envelope[PitchItem]()
+ hi_pitch: Envelope[HiPitchItem] = Envelope[HiPitchItem]()
+ duty_cycle: Envelope[DutyCycleItem] = Envelope[DutyCycleItem]()
+
+ @property
+ def envelope_map(self) -> Dict[FeatureKey, Envelope[int]]:
+ return {
+ FeatureKey.VOLUME: self.volume,
+ FeatureKey.ARPEGGIO: self.arpeggio,
+ FeatureKey.PITCH: self.pitch,
+ FeatureKey.HI_PITCH: self.hi_pitch,
+ FeatureKey.DUTY_CYCLE: self.duty_cycle,
+ }
+
+ def envelope(self, feature_key: FeatureKey) -> Envelope[int]:
+ """The dimension one feature names, empty where the instrument leaves it to the channel.
+
+ Args:
+ feature_key: The dimension read.
+
+ Returns:
+ Envelope[int]: That dimension's items and loop point.
+
+ Raises:
+ KeyError: If ``feature_key`` names a dimension an instrument does not write.
+ """
+ return self.envelope_map[feature_key]
+
+ def with_envelope(
+ self,
+ feature_key: FeatureKey,
+ envelope: Envelope[int],
+ ) -> InstrumentEnvelopes:
+ """The envelopes with one dimension replaced.
+
+ Args:
+ feature_key: The dimension written.
+ envelope: What that dimension now carries; empty items leave it to the channel.
+
+ Returns:
+ InstrumentEnvelopes: The envelopes carrying ``envelope`` for ``feature_key``.
+
+ Raises:
+ KeyError: If ``feature_key`` names a dimension an instrument does not write.
+ """
+ if feature_key not in self.envelope_map:
+ raise KeyError(feature_key)
+
+ return self.model_copy(update={feature_key.value: envelope})
+
+ @property
+ def frame_count(self) -> int:
+ """The ticks the envelopes describe, taken from the longest dimension."""
+ return max((len(envelope.items) for envelope in self.envelope_map.values()), default=0)
diff --git a/src/sampletones_core/project/voices/instrument.py b/src/sampletones_core/project/voices/instrument.py
new file mode 100644
index 000000000..19f9dabf1
--- /dev/null
+++ b/src/sampletones_core/project/voices/instrument.py
@@ -0,0 +1,185 @@
+from functools import cached_property
+from typing import Dict, List, Literal, Self, Tuple
+from uuid import uuid4
+
+from pydantic import BaseModel, ConfigDict, Field
+
+from sampletones_core.constants.enums import ChannelName, FeatureKey
+from sampletones_core.constants.general import (
+ MAX_PERIOD,
+ MAX_PITCH,
+ MIN_PITCH,
+)
+from sampletones_core.exporters.feature import Features
+from sampletones_core.exporters.maps import CHANNEL_TO_EXPORTER_MAP
+from sampletones_core.features import (
+ CHANNEL_GENERATOR_KIND,
+ RESTING_REFERENCE_PERIOD,
+ RESTING_REFERENCE_PITCH,
+ channel_reference,
+ supported_features,
+ supports,
+)
+from sampletones_core.features.envelope import Envelope
+from sampletones_core.instructions import InstructionUnion
+from sampletones_core.project.voices.envelopes import InstrumentEnvelopes
+
+
+def _new_instrument_id() -> str:
+ return uuid4().hex
+
+
+class Instrument(BaseModel):
+ """A hand-written voice: envelopes with no recording behind them, playable on any channel.
+
+ Where a sample carries the frames a conversion found for each channel, an instrument carries
+ one set of envelopes, and every channel reads what it can of them — the dimensions its
+ generator offers, measured against the root the instrument states. That is the model
+ FamiTracker itself holds, so one reaches a tracker as it stands and sounds here as the frames
+ each channel makes of it.
+
+ An instrument carries no payload beyond what it states, so a project stores it whole rather than
+ beside itself: this is both the voice a song plays and the record a ``project.json`` holds.
+
+ Attributes:
+ id: Stable id the tracker rows reference.
+ name: The name the voice list shows.
+ envelopes: The per-tick values every channel reads, each with its own loop point.
+ initial_pitch: The note a tonal channel measures the arpeggio against.
+ initial_period: The period the noise channel measures the arpeggio against.
+ """
+
+ model_config = ConfigDict(extra="forbid")
+
+ kind: Literal["instrument"] = "instrument"
+ id: str = Field(default_factory=_new_instrument_id, description="Stable instrument id.")
+ name: str = Field(..., description="Instrument name.")
+ envelopes: InstrumentEnvelopes = Field(default_factory=InstrumentEnvelopes)
+ initial_pitch: int = Field(
+ default=RESTING_REFERENCE_PITCH,
+ ge=MIN_PITCH,
+ le=MAX_PITCH,
+ description="Note a tonal channel measures the arpeggio envelope against.",
+ )
+ initial_period: int = Field(
+ default=RESTING_REFERENCE_PERIOD,
+ ge=0,
+ le=MAX_PERIOD,
+ description="Period the noise channel measures the arpeggio envelope against.",
+ )
+
+ def reference(self, channel_name: ChannelName) -> int:
+ """The value this channel measures the arpeggio envelope against."""
+ return channel_reference(
+ channel_name,
+ pitch=self.initial_pitch,
+ period=self.initial_period,
+ )
+
+ def held_features(self, channel_name: ChannelName) -> Tuple[FeatureKey, ...]:
+ """The dimensions this channel governs: those it offers and the instrument leaves empty."""
+ kind = CHANNEL_GENERATOR_KIND[channel_name]
+ return tuple(
+ feature_key for feature_key in supported_features(kind) if not self.envelopes.envelope(feature_key).written
+ )
+
+ def features(self, channel_name: ChannelName) -> Features:
+ """The envelopes as this channel reads them, measured against the instrument's pitch.
+
+ A channel takes the dimensions its generator offers and leaves the rest absent, which is
+ what makes one set of envelopes serve every channel. Each dimension travels with the item
+ it repeats from, so a channel reads a loop the way the instrument wrote it.
+
+ Args:
+ channel_name: The channel reading the instrument.
+
+ Returns:
+ Features: The per-dimension envelopes for that channel.
+ """
+ return Features.of(self.reference(channel_name), self._offered(channel_name))
+
+ def instrument_features(self) -> Features:
+ """The envelopes as a tracker instrument holds them: every dimension the instrument writes.
+
+ A tracker instrument is one set of sequences whatever channel plays it, and each channel
+ reads what it can of them, so this is the whole of what a tracker export writes.
+
+ Returns:
+ Features: The envelopes, measured against the instrument's tonal pitch.
+ """
+ return Features.of(self.initial_pitch, self.envelopes.envelope_map)
+
+ def instruction_at(self, channel_name: ChannelName, tick: int) -> InstructionUnion:
+ """The frame this channel sounds at any tick of a held note.
+
+ Every dimension is defined at every tick — it circles from its loop point or holds its
+ last item — so a note goes on sounding for as long as a row asks for it, and a volume
+ envelope ending at silence is what releases it.
+
+ Args:
+ channel_name: The channel sounding the instrument.
+ tick: Ticks since the note started.
+
+ Returns:
+ InstructionUnion: The frame standing at that tick.
+ """
+ standing = {
+ feature_key: Envelope[int](items=(item,)) if (item := envelope.at(tick)) is not None else Envelope[int]()
+ for feature_key, envelope in self._offered(channel_name).items()
+ }
+ features = Features.of(self.reference(channel_name), standing)
+ return CHANNEL_TO_EXPORTER_MAP[channel_name].from_features(features)[0]
+
+ def _offered(self, channel_name: ChannelName) -> Dict[FeatureKey, Envelope[int]]:
+ """The dimensions this channel's generator reads, as the instrument writes them."""
+ kind = CHANNEL_GENERATOR_KIND[channel_name]
+ return {
+ feature_key: envelope
+ for feature_key, envelope in self.envelopes.envelope_map.items()
+ if supports(kind, feature_key)
+ }
+
+ @cached_property
+ def _instructions(self) -> Dict[ChannelName, List[InstructionUnion]]:
+ return {
+ channel_name: list(CHANNEL_TO_EXPORTER_MAP[channel_name].from_features(self.features(channel_name)))
+ for channel_name in ChannelName.items()
+ }
+
+ def instructions(self, channel_name: ChannelName) -> List[InstructionUnion]:
+ """The frames this channel plays, one per tick of the envelopes.
+
+ Args:
+ channel_name: The channel sounding the instrument.
+
+ Returns:
+ List[InstructionUnion]: The frames, empty where the instrument writes no envelope.
+ """
+ return self._instructions[channel_name]
+
+ def invalidate(self) -> None:
+ """Drops the memoized frames so they are made afresh from the envelopes they describe.
+
+ The frames are read from the envelopes once and kept, so whoever writes ``envelopes``
+ calls this in the same breath. That pairing is what keeps what an instrument plays and
+ what it states the same thing.
+ """
+ self.__dict__.pop("_instructions", None)
+
+ def clone(self) -> Self:
+ """Return an independent copy with a fresh id, carrying the name, pitch and envelopes."""
+ return type(self)(
+ name=self.name,
+ envelopes=self.envelopes,
+ initial_pitch=self.initial_pitch,
+ initial_period=self.initial_period,
+ )
+
+ def __hash__(self) -> int:
+ return hash(self.id)
+
+ def __eq__(self, other: object) -> bool:
+ return isinstance(other, Instrument) and self.id == other.id
+
+ def __repr__(self) -> str:
+ return f"Instrument(id={self.id!r}, name={self.name!r})"
diff --git a/src/sampletones_core/project/instruments/note_off.py b/src/sampletones_core/project/voices/note_off.py
similarity index 87%
rename from src/sampletones_core/project/instruments/note_off.py
rename to src/sampletones_core/project/voices/note_off.py
index eb46b75d2..eda84944b 100644
--- a/src/sampletones_core/project/instruments/note_off.py
+++ b/src/sampletones_core/project/voices/note_off.py
@@ -7,7 +7,7 @@ class NoteOff(BaseModel):
Distinct from an empty cell (no command at all): an empty cell lets a sustaining or looped
voice keep playing, whereas a note-off explicitly cuts it. Carries no data — its presence is
the command — and is rendered as ``--``. ``extra="forbid"`` keeps it disjoint from
- :class:`Instrument` so the note-column union round-trips unambiguously.
+ :class:`NoteOn` so the note-column union round-trips unambiguously.
"""
model_config = ConfigDict(frozen=True, extra="forbid")
diff --git a/src/sampletones_core/project/voices/note_on.py b/src/sampletones_core/project/voices/note_on.py
new file mode 100644
index 000000000..0608ffaa5
--- /dev/null
+++ b/src/sampletones_core/project/voices/note_on.py
@@ -0,0 +1,14 @@
+from pydantic import BaseModel, ConfigDict, Field
+
+
+class NoteOn(BaseModel):
+ """A note-on command in a tracker row's note column: start the named voice on this channel.
+
+ The voice is referenced by its stable ``id``, so the reference survives reordering of the
+ project's voice collection. The channel a voice sounds on is the one whose pattern holds the
+ row, which is what lets one voice be started on any channel it suits.
+ """
+
+ model_config = ConfigDict(frozen=True, extra="forbid")
+
+ voice_id: str = Field(..., description="Stable id of the voice to start.")
diff --git a/src/sampletones_core/project/instruments/record.py b/src/sampletones_core/project/voices/record.py
similarity index 58%
rename from src/sampletones_core/project/instruments/record.py
rename to src/sampletones_core/project/voices/record.py
index 03f5722b1..0d0a67b3a 100644
--- a/src/sampletones_core/project/instruments/record.py
+++ b/src/sampletones_core/project/voices/record.py
@@ -1,13 +1,21 @@
+from typing import Annotated, Literal, Union
+
from pydantic import BaseModel, Field
+from sampletones_core.project.voices.instrument import Instrument
+
class SampleRecord(BaseModel):
"""The on-disk form of a sample: its identity plus a reference to the
reconstruction stored separately in the archive."""
+ kind: Literal["sample"] = Field(default="sample", description="Which kind of voice this record carries.")
id: str = Field(..., description="Stable sample id.")
name: str = Field(..., description="Sample name.")
reconstruction_id: str = Field(
...,
description="Id of the reconstruction stored in the archive.",
)
+
+
+VoiceRecord = Annotated[Union[SampleRecord, Instrument], Field(discriminator="kind")]
diff --git a/src/sampletones_core/project/instruments/sample.py b/src/sampletones_core/project/voices/sample.py
similarity index 70%
rename from src/sampletones_core/project/instruments/sample.py
rename to src/sampletones_core/project/voices/sample.py
index 672abb09b..d9062ff4c 100644
--- a/src/sampletones_core/project/instruments/sample.py
+++ b/src/sampletones_core/project/voices/sample.py
@@ -5,28 +5,31 @@
class Sample:
+ """A reconstruction placed in a project as a playable voice.
+
+ The reconstruction carries one instruction stream per channel; the sample adds what a song
+ needs of it — a name and a stable id the tracker rows reference. Its frames are the run its
+ conversion found, so a note sounds them through and the channel then rests.
+ """
+
def __init__(
self,
name: str,
reconstruction: Reconstruction,
- *,
- loop: bool = False,
) -> None:
self.id: str = uuid4().hex
self.name: str = name
self.reconstruction: Reconstruction = reconstruction
- self.loop: bool = loop
def clone(self) -> Self:
"""Return an independent copy with a fresh id.
The reconstruction is deep-copied so the copy can be edited independently of
- the original; the name and loop flag are carried over.
+ the original; the name is carried over.
"""
return type(self)(
name=self.name,
reconstruction=self.reconstruction.model_copy(deep=True),
- loop=self.loop,
)
def __hash__(self) -> int:
diff --git a/src/sampletones_core/project/voices/voice.py b/src/sampletones_core/project/voices/voice.py
new file mode 100644
index 000000000..be442d89a
--- /dev/null
+++ b/src/sampletones_core/project/voices/voice.py
@@ -0,0 +1,61 @@
+from typing import Iterable, Iterator, Tuple, Union
+
+from sampletones_core.constants.enums import ChannelName
+from sampletones_core.project.voices.instrument import Instrument
+from sampletones_core.project.voices.sample import Sample
+
+VoiceUnion = Union[Sample, Instrument]
+
+
+def samples(voices: Iterable[VoiceUnion]) -> Iterator[Sample]:
+ """The samples among a project's voices — those a reconstruction stands behind.
+
+ Work that reads recorded audio — retuning, rendering a waveform, opening a document in the
+ Reconstructions tab — concerns those alone, so it walks them rather than every voice.
+
+ Args:
+ voices: The project's voices.
+
+ Yields:
+ Sample: Each voice a reconstruction stands behind.
+ """
+ return (voice for voice in voices if isinstance(voice, Sample))
+
+
+def voice_channels(voice: VoiceUnion) -> Tuple[ChannelName, ...]:
+ """The channels a voice sounds on.
+
+ A sample sounds on the channels its reconstruction found frames for; an instrument sounds wherever
+ its envelopes make a frame, which is every channel once it writes one.
+
+ Args:
+ voice: The voice being placed.
+
+ Returns:
+ Tuple[ChannelName, ...]: The channels it sounds on, in channel order.
+ """
+ match voice:
+ case Sample():
+ return voice.reconstruction.playing_channels
+ case Instrument():
+ return tuple(channel for channel in ChannelName.items() if voice.instructions(channel))
+
+
+def voice_reference(voice: VoiceUnion, channel_name: ChannelName) -> int:
+ """The value a voice's arpeggio is measured against on one channel.
+
+ A sample carries the reference its conversion chose for that channel; an instrument states the root
+ the reader gave it. A row's transpose is the step from this, whichever kind it names.
+
+ Args:
+ voice: The voice being sounded.
+ channel_name: The channel sounding it.
+
+ Returns:
+ int: The pitch, or the period, the voice rests at on that channel.
+ """
+ match voice:
+ case Sample():
+ return voice.reconstruction.initial_pitches[channel_name]
+ case Instrument():
+ return voice.reference(channel_name)
diff --git a/src/sampletones_core/reconstructions/__init__.py b/src/sampletones_core/reconstructions/__init__.py
index 4608b5499..dfebc7089 100644
--- a/src/sampletones_core/reconstructions/__init__.py
+++ b/src/sampletones_core/reconstructions/__init__.py
@@ -1,7 +1,8 @@
from .criterion import Criterion
from .reconstruction.reconstruction import Reconstruction
-from .reconstructor.approximation import ApproximationData
from .reconstructor.candidates import CandidateProvider
+from .reconstructor.decoder import Decoder, GreedyDecoder, ViterbiDecoder
+from .reconstructor.matching import FrameMatcher, ScoredCandidate
from .reconstructor.phase import (
CrossCorrelationPhaseAligner,
PhaseAligner,
@@ -9,7 +10,6 @@
)
from .reconstructor.reconstructor import Reconstructor
from .reconstructor.scorer import Scorer
-from .reconstructor.selector import GreedySelector, Selector, ViterbiSelector
from .reconstructor.state import (
FragmentReconstructionState,
ReconstructionState,
@@ -17,19 +17,20 @@
from .reconstructor.worker import ReconstructorWorker
__all__ = [
- "ApproximationData",
"CandidateProvider",
"Criterion",
"CrossCorrelationPhaseAligner",
+ "Decoder",
"FragmentReconstructionState",
- "GreedySelector",
+ "FrameMatcher",
+ "GreedyDecoder",
"PhaseAligner",
"Reconstruction",
"ReconstructionState",
"Reconstructor",
"ReconstructorWorker",
+ "ScoredCandidate",
"Scorer",
- "Selector",
"SlidingRmsePhaseAligner",
- "ViterbiSelector",
+ "ViterbiDecoder",
]
diff --git a/src/sampletones_core/reconstructions/converter/__init__.py b/src/sampletones_core/reconstructions/converter/__init__.py
index c85133963..d40870f70 100644
--- a/src/sampletones_core/reconstructions/converter/__init__.py
+++ b/src/sampletones_core/reconstructions/converter/__init__.py
@@ -1,19 +1,29 @@
-from .conversion import reconstruct_file
+from .conversion import reconstruct_job
from .converter import ReconstructionConverter
+from .job import ConversionJob
from .paths.fields import ConfigDirectoryFields
from .paths.utils import (
filter_files,
get_audio_files,
get_output_path,
get_relative_path,
+ group_output_path,
+ top_level_audio_files,
)
+from .plan import ConversionPlan, DirectoryConversion, GroupConversion
__all__ = [
"ConfigDirectoryFields",
+ "ConversionJob",
+ "ConversionPlan",
+ "DirectoryConversion",
+ "GroupConversion",
"ReconstructionConverter",
"filter_files",
"get_audio_files",
"get_output_path",
"get_relative_path",
- "reconstruct_file",
+ "group_output_path",
+ "reconstruct_job",
+ "top_level_audio_files",
]
diff --git a/src/sampletones_core/reconstructions/converter/conversion.py b/src/sampletones_core/reconstructions/converter/conversion.py
index 1d3dfe158..0306ee774 100644
--- a/src/sampletones_core/reconstructions/converter/conversion.py
+++ b/src/sampletones_core/reconstructions/converter/conversion.py
@@ -5,24 +5,39 @@
from sampletones_shared.exceptions import UnsupportedAudioFormatError
from sampletones_shared.logger import logger
+from ..progress import ReconstructionReporter
from ..reconstructor.reconstructor import Reconstructor
+from .job import ConversionJob
-def reconstruct_file(arguments: Tuple[Reconstructor, Path, Path]) -> Path:
- reconstructor, input_path, output_path = arguments
- output_path.parent.mkdir(parents=True, exist_ok=True)
+def reconstruct_job(arguments: Tuple[Reconstructor, ConversionJob, ReconstructionReporter]) -> Path:
+ """Builds one job's reconstruction and writes it where the job says.
+
+ Runs in a pool worker, so the job travels with the reconstructor that builds it and the
+ reporter it tells its progress to. A source in a format the loader has no reader for is
+ reported and left, which keeps one such file from ending a batch.
+
+ Returns:
+ The file the job named, whether or not a reconstruction reached it.
+
+ Raises:
+ KeyboardInterrupt: If the run is interrupted, so the pool stops.
+ OperationCanceled: If the run is withdrawn while the job is under way.
+ """
+ reconstructor, job, report = arguments
+ job.output_path.parent.mkdir(parents=True, exist_ok=True)
reconstruction = None
try:
- reconstruction = reconstructor(input_path)
+ reconstruction = reconstructor.reconstruct(job.sources, job.stems, report=report)
if reconstruction is not None:
- reconstruction.save(output_path)
+ reconstruction.save(job.output_path)
del reconstruction
except KeyboardInterrupt:
logger.info("Reconstruction interrupted by user.")
raise
except UnsupportedAudioFormatError:
- logger.warning(f"Skipping file due to unsupported audio format: {input_path}")
+ logger.warning(f"Skipping job due to unsupported audio format: {job.sources}")
finally:
gc.collect()
- return output_path
+ return job.output_path
diff --git a/src/sampletones_core/reconstructions/converter/converter.py b/src/sampletones_core/reconstructions/converter/converter.py
index fac0b9c91..61d28a4fe 100644
--- a/src/sampletones_core/reconstructions/converter/converter.py
+++ b/src/sampletones_core/reconstructions/converter/converter.py
@@ -3,35 +3,35 @@
from sampletones_core.configs import Config
from sampletones_core.parallelization import TaskProcessor
-from sampletones_shared.exceptions import NoFilesToProcessError
from sampletones_shared.logger import LoggerProtocol
from sampletones_shared.logger import logger as default_logger
+from ..progress import ReconstructionReporter
from ..reconstructor.reconstructor import Reconstructor
-from .conversion import reconstruct_file
-from .paths import (
- filter_files,
- get_audio_files,
- get_output_path,
- get_relative_path,
-)
+from .conversion import reconstruct_job
+from .job import ConversionJob
+from .plan.protocol import ConversionPlan
+from .progress import JobReporter
class ReconstructionConverter(TaskProcessor[Path]):
+ """Runs a conversion plan's jobs across a pool of worker processes.
+
+ The plan is resolved once the run starts, on the monitor thread, so a plan that scans a
+ directory does its reading there. Every job is then built by one worker, and the run
+ reports the reconstructions it wrote.
+ """
+
def __init__(
self,
config: Config,
- input_path: Path,
- is_file: bool,
+ plan: ConversionPlan,
logger: LoggerProtocol = default_logger,
) -> None:
super().__init__(max_workers=config.general.max_workers, logger=logger)
self.config = config.model_copy()
- self.input_path: Path = input_path
- self.is_file: bool = is_file
- self.audio_files: List[Path] = []
-
- self.current_file: Optional[str] = None
+ self.plan: ConversionPlan = plan
+ self.jobs: List[ConversionJob] = []
def start(self) -> None:
if self.running:
@@ -42,38 +42,31 @@ def start(self) -> None:
def _create_tasks(self) -> List[Any]:
reconstructor = Reconstructor(self.config)
- output_path = get_output_path(self.config, self.input_path)
-
- if self.is_file:
- return [(reconstructor, self.input_path, output_path)]
-
- self.audio_files = get_audio_files(self.input_path)
- self.audio_files = filter_files(self.audio_files, self.input_path, output_path)
-
- arguments: List[Tuple[Reconstructor, Path, Path]] = []
- for audio_file in self.audio_files:
- target_path = get_relative_path(self.input_path, audio_file, output_path)
- arguments.append((reconstructor, audio_file, target_path))
-
- if not arguments:
- raise NoFilesToProcessError(f"No audio files found in {self.input_path}")
-
- return arguments
+ self.jobs = self.plan.jobs(self.config)
+ return [(reconstructor, job, JobReporter(self._task_reporter(index))) for index, job in enumerate(self.jobs)]
def _get_task_function(
self,
- ) -> Callable[[Tuple[Reconstructor, Path, Path]], Path]:
- return reconstruct_file
-
- def _process_results(self, results: List[Path]) -> Path:
- if self.is_file:
- return results[0]
+ ) -> Callable[[Tuple[Reconstructor, ConversionJob, ReconstructionReporter]], Path]:
+ return reconstruct_job
- return self.input_path
+ def _process_results(self, results: List[Path]) -> Tuple[Path, ...]:
+ """The reconstructions the run wrote, in job order."""
+ return tuple(output_path for output_path in results if output_path.exists())
def _notify_progress(self) -> None:
- if self.completed_tasks > 0 and self.completed_tasks <= len(self.audio_files):
- self.current_file = str(self.audio_files[self.completed_tasks - 1])
- self.current_item = self.current_file
-
+ self.current_item = self._running_source()
super()._notify_progress()
+
+ def _running_source(self) -> Optional[str]:
+ """The recording the job the run has been working on longest is reading.
+
+ Jobs are answered in the order they were handed out, so the first one the run has yet to
+ count is the earliest still under way — and once every job is counted, the last one is
+ what the run finished on.
+ """
+ if not self.jobs:
+ return None
+
+ index = min(self.completed_tasks, len(self.jobs) - 1)
+ return str(self.jobs[index].sources[0])
diff --git a/src/sampletones_core/reconstructions/converter/job.py b/src/sampletones_core/reconstructions/converter/job.py
new file mode 100644
index 000000000..5d63a0398
--- /dev/null
+++ b/src/sampletones_core/reconstructions/converter/job.py
@@ -0,0 +1,20 @@
+from dataclasses import dataclass
+from pathlib import Path
+from typing import Tuple
+
+from sampletones_core.reconstructions.reconstructor.stems.configs.config import StemsConfig
+
+
+@dataclass(frozen=True)
+class ConversionJob:
+ """One reconstruction to build, and everything needed to build it.
+
+ A job carries the recordings that are mixed into the target, the stems setup that hands
+ their channels out, and the file the result is written to. It is the unit a conversion
+ is divided into: a classic conversion is one job over one source, a stems conversion one
+ job over several, and a batch as many single-source jobs as the directory holds.
+ """
+
+ sources: Tuple[Path, ...]
+ stems: StemsConfig
+ output_path: Path
diff --git a/src/sampletones_core/reconstructions/converter/paths/__init__.py b/src/sampletones_core/reconstructions/converter/paths/__init__.py
index 32e362fc2..4cb6f7fb1 100644
--- a/src/sampletones_core/reconstructions/converter/paths/__init__.py
+++ b/src/sampletones_core/reconstructions/converter/paths/__init__.py
@@ -6,6 +6,8 @@
get_audio_files,
get_output_path,
get_relative_path,
+ group_output_path,
+ top_level_audio_files,
)
__all__ = [
@@ -14,4 +16,6 @@
"get_audio_files",
"get_output_path",
"get_relative_path",
+ "group_output_path",
+ "top_level_audio_files",
]
diff --git a/src/sampletones_core/reconstructions/converter/paths/fields.py b/src/sampletones_core/reconstructions/converter/paths/fields.py
index 9ff18843c..e284dd05a 100644
--- a/src/sampletones_core/reconstructions/converter/paths/fields.py
+++ b/src/sampletones_core/reconstructions/converter/paths/fields.py
@@ -9,11 +9,11 @@
format_transformation,
)
from sampletones_core.constants.enums import (
- GENERATOR_ABBREVIATION_PATTERN,
- GENERATOR_ABBREVIATION_TO_NAME,
- GeneratorName,
+ CHANNEL_ABBREVIATION_PATTERN,
+ CHANNEL_ABBREVIATION_TO_NAME,
+ ChannelName,
SpectrumMethod,
- abbreviate_generator_names,
+ abbreviate_channel_names,
)
from sampletones_core.constants.field_aliases import ALIASES
from sampletones_shared.utils.serialization import HASH_PATTERN, hash_models
@@ -37,12 +37,12 @@ class ConfigDirectoryFields(BaseModel):
nf: int = Field(gt=0, validation_alias=ALIASES["nf"])
sm: SpectrumMethod = Field(validation_alias=ALIASES["sm"])
tg: int = Field(ge=0, validation_alias=ALIASES["tg"])
- gn: str = Field(pattern=GENERATOR_ABBREVIATION_PATTERN, validation_alias=ALIASES["gn"])
+ gn: str = Field(pattern=CHANNEL_ABBREVIATION_PATTERN, validation_alias=ALIASES["gn"])
ch: str = Field(pattern=HASH_PATTERN, validation_alias=ALIASES["ch"])
@property
- def generators(self) -> Tuple[GeneratorName, ...]:
- return tuple(GENERATOR_ABBREVIATION_TO_NAME[character] for character in self.gn)
+ def channels(self) -> Tuple[ChannelName, ...]:
+ return tuple(CHANNEL_ABBREVIATION_TO_NAME[character] for character in self.gn)
@classmethod
def from_config(cls, config: Config) -> Self:
@@ -51,7 +51,7 @@ def from_config(cls, config: Config) -> Self:
nf=config.library.nes_frequency,
sm=config.library.spectrum_method,
tg=config.library.transformation_gamma,
- gn=abbreviate_generator_names(config.generation.generators),
+ gn=abbreviate_channel_names(config.generation.channels),
ch=hash_models(config.library, config.generation),
)
diff --git a/src/sampletones_core/reconstructions/converter/paths/utils.py b/src/sampletones_core/reconstructions/converter/paths/utils.py
index 3069f11b1..4a2269790 100644
--- a/src/sampletones_core/reconstructions/converter/paths/utils.py
+++ b/src/sampletones_core/reconstructions/converter/paths/utils.py
@@ -5,6 +5,7 @@
from sampletones_core.reconstructions.converter.paths.fields import (
ConfigDirectoryFields,
)
+from sampletones_core.reconstructions.naming.derive import derive_name
from sampletones_shared.paths.extensions import (
EXT_FILE_RECONSTRUCTION,
EXT_FILES_AUDIO,
@@ -43,6 +44,25 @@ def get_output_path(
raise OSError(f"Invalid path: {input_path}")
+def group_output_path(
+ config: Config,
+ sources: Tuple[Path, ...],
+ suffix: str = EXT_FILE_RECONSTRUCTION,
+) -> Path:
+ """Where the one reconstruction built from ``sources`` is written.
+
+ The file sits in the configuration's own directory, under the name the source rules derive:
+ one source names it after itself, and several after what they share
+ (:func:`sampletones_core.reconstructions.naming.derive.derive_name`).
+
+ Raises:
+ ValueError: If ``sources`` is empty.
+ """
+ config_directory = ConfigDirectoryFields.generate_config_directory_name(config)
+ output_directory = to_path(config.general.reconstructions_directory) / config_directory
+ return Path((output_directory / f"{derive_name(sources)}{suffix}").absolute())
+
+
def get_audio_files(
input_directory: Path,
extensions: Tuple[str, ...] = EXT_FILES_AUDIO,
@@ -55,6 +75,21 @@ def get_audio_files(
return audio_files
+def top_level_audio_files(
+ input_directory: Path,
+ extensions: Tuple[str, ...] = EXT_FILES_AUDIO,
+) -> List[Path]:
+ """The audio files sitting directly in a directory, in name order.
+
+ Where a batch reaches every recording below a folder, gathering the sources of one
+ reconstruction stays with the folder a reader pointed at, so what it offers is what that
+ folder itself holds.
+ """
+ audio_files = [path for path in input_directory.iterdir() if path.is_file() and path.suffix.lower() in extensions]
+ audio_files.sort()
+ return audio_files
+
+
def filter_files(
audio_files: List[Path],
base_directory: Path,
diff --git a/src/sampletones_core/reconstructions/converter/plan/__init__.py b/src/sampletones_core/reconstructions/converter/plan/__init__.py
new file mode 100644
index 000000000..7b7442d8e
--- /dev/null
+++ b/src/sampletones_core/reconstructions/converter/plan/__init__.py
@@ -0,0 +1,9 @@
+from .directory import DirectoryConversion
+from .group import GroupConversion
+from .protocol import ConversionPlan
+
+__all__ = [
+ "ConversionPlan",
+ "DirectoryConversion",
+ "GroupConversion",
+]
diff --git a/src/sampletones_core/reconstructions/converter/plan/directory.py b/src/sampletones_core/reconstructions/converter/plan/directory.py
new file mode 100644
index 000000000..c27a0abdf
--- /dev/null
+++ b/src/sampletones_core/reconstructions/converter/plan/directory.py
@@ -0,0 +1,51 @@
+from dataclasses import dataclass
+from pathlib import Path
+from typing import List, Tuple
+
+from sampletones_core.configs import Config
+from sampletones_core.reconstructions.converter.job import ConversionJob
+from sampletones_core.reconstructions.converter.paths.utils import (
+ filter_files,
+ get_audio_files,
+ get_output_path,
+ get_relative_path,
+)
+from sampletones_core.reconstructions.reconstructor.stems.configs.config import StemsConfig
+from sampletones_shared.exceptions import NoFilesToProcessError
+
+
+@dataclass(frozen=True)
+class DirectoryConversion:
+ """One reconstruction per audio file under a directory, each built from that file alone.
+
+ The scan reaches every audio file below the directory and keeps those whose reconstruction
+ is still to be written, so a repeated run picks up where the last one stopped. The output
+ tree mirrors the input tree, and every file is converted under the same stems setup.
+ """
+
+ directory: Path
+ stems: StemsConfig
+
+ def jobs(self, config: Config) -> List[ConversionJob]:
+ """The single-source jobs the directory holds.
+
+ Raises:
+ NoFilesToProcessError: If the directory holds no audio file still to be converted.
+ """
+ output_path = get_output_path(config, self.directory)
+ audio_files = filter_files(get_audio_files(self.directory), self.directory, output_path)
+ if not audio_files:
+ raise NoFilesToProcessError(f"No audio files found in {self.directory}")
+
+ return [self._job(audio_file, output_path) for audio_file in audio_files]
+
+ def existing_targets(self, _config: Config) -> Tuple[Path, ...]:
+ """The empty tuple: the scan converts what is still to be written and keeps the rest."""
+ return ()
+
+ def _job(self, audio_file: Path, output_path: Path) -> ConversionJob:
+ return ConversionJob(
+ sources=(audio_file,),
+ stems=self.stems,
+ output_path=get_relative_path(self.directory, audio_file, output_path),
+ )
diff --git a/src/sampletones_core/reconstructions/converter/plan/group.py b/src/sampletones_core/reconstructions/converter/plan/group.py
new file mode 100644
index 000000000..e04989b2e
--- /dev/null
+++ b/src/sampletones_core/reconstructions/converter/plan/group.py
@@ -0,0 +1,37 @@
+from dataclasses import dataclass
+from pathlib import Path
+from typing import List, Tuple
+
+from sampletones_core.configs import Config
+from sampletones_core.reconstructions.converter.job import ConversionJob
+from sampletones_core.reconstructions.converter.paths.utils import group_output_path
+from sampletones_core.reconstructions.reconstructor.stems.configs.config import StemsConfig
+
+
+@dataclass(frozen=True)
+class GroupConversion:
+ """One reconstruction from the recordings given, mixed together under one stems setup.
+
+ One source is the classic conversion and several are the stems case; both amount to the
+ same single job, which is what lets one conversion path serve them.
+ """
+
+ sources: Tuple[Path, ...]
+ stems: StemsConfig
+
+ def jobs(self, config: Config) -> List[ConversionJob]:
+ return [
+ ConversionJob(
+ sources=self.sources,
+ stems=self.stems,
+ output_path=self._output_path(config),
+ )
+ ]
+
+ def existing_targets(self, config: Config) -> Tuple[Path, ...]:
+ """The one file this conversion writes, where it already stands."""
+ output_path = self._output_path(config)
+ return (output_path,) if output_path.is_file() else ()
+
+ def _output_path(self, config: Config) -> Path:
+ return group_output_path(config, self.sources)
diff --git a/src/sampletones_core/reconstructions/converter/plan/protocol.py b/src/sampletones_core/reconstructions/converter/plan/protocol.py
new file mode 100644
index 000000000..287f4daa3
--- /dev/null
+++ b/src/sampletones_core/reconstructions/converter/plan/protocol.py
@@ -0,0 +1,25 @@
+from pathlib import Path
+from typing import List, Protocol, Tuple
+
+from sampletones_core.configs import Config
+from sampletones_core.reconstructions.converter.job import ConversionJob
+
+
+class ConversionPlan(Protocol):
+ """What a conversion request amounts to: the reconstructions it builds.
+
+ A plan answers with the jobs the request divides into, in the order they are run, resolved
+ against the configuration the run uses, since that is what settles where each reconstruction
+ is written. Resolving happens on the converter's own thread, so a plan that reads the
+ filesystem does so away from the interface.
+ """
+
+ def jobs(self, config: Config) -> List[ConversionJob]: ...
+
+ def existing_targets(self, config: Config) -> Tuple[Path, ...]:
+ """The reconstructions already standing where this plan would write.
+
+ A caller asks this ahead of the run, on its own thread, so the answer stays cheap: a
+ run that would write over work already done is one the reader gets to answer for. A
+ plan that keeps standing files as it goes answers with none, having settled it already.
+ """
diff --git a/src/sampletones_core/reconstructions/converter/progress.py b/src/sampletones_core/reconstructions/converter/progress.py
new file mode 100644
index 000000000..4b315bd32
--- /dev/null
+++ b/src/sampletones_core/reconstructions/converter/progress.py
@@ -0,0 +1,59 @@
+from typing import Optional
+
+from sampletones_core.parallelization.channel.protocol import StepReporter
+from sampletones_core.parallelization.task import TaskStep
+from sampletones_core.reconstructions.progress import ReconstructionProgress
+from sampletones_core.reconstructions.stage import ReconstructionStage
+from sampletones_shared.utils.progress import ReportRate
+
+
+class JobReporter:
+ """Carries one job's account of itself back to the run that handed it out.
+
+ A reconstruction says where it stands frame by frame, far more often than a line can carry or a
+ bar can be redrawn, so each stage is reported at the spacing its own length sets. The stage is
+ weighed into a reading of the whole job before it travels, since the run counts jobs and knows
+ nothing of the frames one is made of.
+
+ The line answers in both directions, so a job that hears the run has been let go of says so
+ where it stands and the reconstruction unwinds from that frame.
+ """
+
+ def __init__(self, report: StepReporter) -> None:
+ """Holds the line one job reports on.
+
+ Args:
+ report: Carries a step to the run, and answers whether that run goes on.
+ """
+ self._report = report
+ self._stage: Optional[ReconstructionStage] = None
+ self._rate: Optional[ReportRate] = None
+
+ def __call__(self, progress: ReconstructionProgress) -> bool:
+ """Files the job's step where one is due, and answers whether the run goes on.
+
+ Args:
+ progress: Where the reconstruction now stands.
+
+ Returns:
+ bool: Whether the run still wants the reconstruction this job is building.
+ """
+ if not self._rate_for(progress).take(progress.completed):
+ return True
+
+ return self._report(
+ TaskStep(
+ stage=progress.stage.value,
+ completed=progress.completed,
+ total=progress.total,
+ fraction=progress.fraction,
+ )
+ )
+
+ def _rate_for(self, progress: ReconstructionProgress) -> ReportRate:
+ """The spacing the stage is reported at, which each stage sets by its own length."""
+ if self._rate is None or self._stage != progress.stage:
+ self._stage = progress.stage
+ self._rate = ReportRate(progress.total)
+
+ return self._rate
diff --git a/src/sampletones_core/reconstructions/criterion/criterion.py b/src/sampletones_core/reconstructions/criterion/criterion.py
index 95e5eb3c1..2ad37a4af 100644
--- a/src/sampletones_core/reconstructions/criterion/criterion.py
+++ b/src/sampletones_core/reconstructions/criterion/criterion.py
@@ -6,7 +6,7 @@
from sampletones_core.structures.histogram import Histogram
from sampletones_shared.array import xp
-from .spectral import calculate_spectral_loss
+from .spectral import calculate_spectral_loss, weighted_reference_energy
from .temporal import calculate_temporal_loss
from .weights import calculate_spectral_weights
@@ -55,6 +55,22 @@ def spectral_loss(
divergence_beta=self.divergence_beta,
)
+ def reference_energy(self, feature: Union[xp.ndarray, Histogram]) -> xp.ndarray:
+ """
+ Weighted energy the target feature holds, the scale its spectral loss is measured against.
+
+ Args:
+ feature: Target feature, as a histogram or its values.
+
+ Returns:
+ The target's weighted energy.
+ """
+ return weighted_reference_energy(
+ _feature_values(feature),
+ self.weights,
+ distance=self.spectral_distance,
+ )
+
def temporal_loss(
self,
audio: xp.ndarray,
diff --git a/src/sampletones_core/reconstructions/criterion/spectral.py b/src/sampletones_core/reconstructions/criterion/spectral.py
index 8324d3ba5..2c75e988d 100644
--- a/src/sampletones_core/reconstructions/criterion/spectral.py
+++ b/src/sampletones_core/reconstructions/criterion/spectral.py
@@ -47,10 +47,8 @@ def calculate_spectral_loss(
axis=-1,
)
)
- denominator = xp.sqrt(xp.sum(weights * reference**2, axis=-1))
case SpectralDistance.ABSOLUTE:
numerator = xp.sum(weights * xp.abs(candidates - reference), axis=-1)
- denominator = xp.sum(weights * reference, axis=-1)
case SpectralDistance.BETA_DIVERGENCE:
numerator = xp.sum(
weights
@@ -61,13 +59,46 @@ def calculate_spectral_loss(
),
axis=-1,
)
- denominator = xp.sum(weights * reference, axis=-1)
case _:
raise ValueError(f"Unsupported spectral distance: {distance}")
+ denominator = weighted_reference_energy(reference, weights, distance=distance)
return numerator / (denominator + SPECTRUM_FLOOR)
+def weighted_reference_energy(
+ reference: xp.ndarray,
+ weights: xp.ndarray,
+ *,
+ distance: SpectralDistance,
+) -> xp.ndarray:
+ """
+ The target's own weighted energy, the scale its spectral distance is measured against.
+
+ Every distance family divides by this quantity, so a loss reads as a fraction of what the
+ target holds. Read on its own it says how much there is to cover, which is what separates a
+ loud target from a quiet one when two targets are compared.
+
+ Args:
+ reference: Target feature values.
+ weights: Per-bin weights of the configuration.
+ distance: Per-bin distance family the energy is measured for.
+
+ Returns:
+ The weighted energy, in the units the matching distance produces.
+
+ Raises:
+ ValueError: If the spectral distance is unsupported.
+ """
+ match distance:
+ case SpectralDistance.SQUARED:
+ return xp.sqrt(xp.sum(weights * reference**2, axis=-1))
+ case SpectralDistance.ABSOLUTE | SpectralDistance.BETA_DIVERGENCE:
+ return xp.sum(weights * reference, axis=-1)
+ case _:
+ raise ValueError(f"Unsupported spectral distance: {distance}")
+
+
def _prepare(
reference: xp.ndarray,
candidates: xp.ndarray,
diff --git a/src/sampletones_core/reconstructions/naming/__init__.py b/src/sampletones_core/reconstructions/naming/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/src/sampletones_core/reconstructions/naming/derive.py b/src/sampletones_core/reconstructions/naming/derive.py
new file mode 100644
index 000000000..d9bee9d78
--- /dev/null
+++ b/src/sampletones_core/reconstructions/naming/derive.py
@@ -0,0 +1,36 @@
+from pathlib import Path
+from typing import Final, Tuple, Type
+
+from .protocol import NameRule
+from .rules.common_directory import CommonDirectoryRule
+from .rules.common_prefix import CommonPrefixRule
+from .rules.first_source import FirstSourceRule
+from .rules.single_source import SingleSourceRule
+
+SOURCE_RULES: Final[Tuple[Type[NameRule], ...]] = (
+ SingleSourceRule,
+ CommonDirectoryRule,
+ CommonPrefixRule,
+ FirstSourceRule,
+)
+
+
+def derive_name(
+ source_paths: Tuple[Path, ...],
+) -> str:
+ """Names a reconstruction from its sources through the rule hierarchy.
+
+ The first rule that applies derives the name: one source names after itself, sources
+ sharing one directory name after that directory, sources sharing a deeper directory
+ name after the deepest one they share, and every other set names after its first
+ source.
+
+ Raises:
+ ValueError: If the source set is empty.
+ """
+ for rule_class in SOURCE_RULES:
+ rule = rule_class()
+ if rule.applies(source_paths):
+ return rule.derive(source_paths)
+
+ raise ValueError("Source paths must hold at least one path")
diff --git a/src/sampletones_core/reconstructions/naming/protocol.py b/src/sampletones_core/reconstructions/naming/protocol.py
new file mode 100644
index 000000000..d3377661a
--- /dev/null
+++ b/src/sampletones_core/reconstructions/naming/protocol.py
@@ -0,0 +1,10 @@
+from pathlib import Path
+from typing import Protocol, Tuple
+
+
+class NameRule(Protocol):
+ """One step of the naming hierarchy: it states which source sets it names and the name it derives."""
+
+ def applies(self, source_paths: Tuple[Path, ...]) -> bool: ...
+
+ def derive(self, source_paths: Tuple[Path, ...]) -> str: ...
diff --git a/src/sampletones_core/reconstructions/naming/rules/__init__.py b/src/sampletones_core/reconstructions/naming/rules/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/src/sampletones_core/reconstructions/naming/rules/common_directory.py b/src/sampletones_core/reconstructions/naming/rules/common_directory.py
new file mode 100644
index 000000000..c8d060f1f
--- /dev/null
+++ b/src/sampletones_core/reconstructions/naming/rules/common_directory.py
@@ -0,0 +1,12 @@
+from pathlib import Path
+from typing import Tuple
+
+
+class CommonDirectoryRule:
+ """Names a reconstruction after the directory all of its sources share."""
+
+ def applies(self, source_paths: Tuple[Path, ...]) -> bool:
+ return len(source_paths) > 1 and len({path.parent for path in source_paths}) == 1
+
+ def derive(self, source_paths: Tuple[Path, ...]) -> str:
+ return source_paths[0].parent.name
diff --git a/src/sampletones_core/reconstructions/naming/rules/common_prefix.py b/src/sampletones_core/reconstructions/naming/rules/common_prefix.py
new file mode 100644
index 000000000..1e50cd91d
--- /dev/null
+++ b/src/sampletones_core/reconstructions/naming/rules/common_prefix.py
@@ -0,0 +1,21 @@
+import os
+from pathlib import Path
+from typing import Tuple
+
+
+class CommonPrefixRule:
+ """Names a reconstruction after the filename prefix every source shares."""
+
+ def applies(self, source_paths: Tuple[Path, ...]) -> bool:
+ if len(source_paths) < 2:
+ return False
+
+ return bool(self._common_prefix(source_paths))
+
+ def derive(self, source_paths: Tuple[Path, ...]) -> str:
+ return self._common_prefix(source_paths)
+
+ @staticmethod
+ def _common_prefix(source_paths: Tuple[Path, ...]) -> str:
+ stems = [path.stem for path in source_paths]
+ return os.path.commonprefix(stems).rstrip("_- ")
diff --git a/src/sampletones_core/reconstructions/naming/rules/first_source.py b/src/sampletones_core/reconstructions/naming/rules/first_source.py
new file mode 100644
index 000000000..6152387a9
--- /dev/null
+++ b/src/sampletones_core/reconstructions/naming/rules/first_source.py
@@ -0,0 +1,12 @@
+from pathlib import Path
+from typing import Tuple
+
+
+class FirstSourceRule:
+ """Names a reconstruction after its first source recording."""
+
+ def applies(self, source_paths: Tuple[Path, ...]) -> bool:
+ return len(source_paths) >= 1
+
+ def derive(self, source_paths: Tuple[Path, ...]) -> str:
+ return source_paths[0].stem
diff --git a/src/sampletones_core/reconstructions/naming/rules/single_source.py b/src/sampletones_core/reconstructions/naming/rules/single_source.py
new file mode 100644
index 000000000..994acc08f
--- /dev/null
+++ b/src/sampletones_core/reconstructions/naming/rules/single_source.py
@@ -0,0 +1,12 @@
+from pathlib import Path
+from typing import Tuple
+
+
+class SingleSourceRule:
+ """Names a reconstruction after its one source recording."""
+
+ def applies(self, source_paths: Tuple[Path, ...]) -> bool:
+ return len(source_paths) == 1
+
+ def derive(self, source_paths: Tuple[Path, ...]) -> str:
+ return source_paths[0].stem
diff --git a/src/sampletones_core/reconstructions/progress.py b/src/sampletones_core/reconstructions/progress.py
new file mode 100644
index 000000000..73fedf19f
--- /dev/null
+++ b/src/sampletones_core/reconstructions/progress.py
@@ -0,0 +1,71 @@
+from dataclasses import dataclass
+from typing import Callable, Final
+
+from sampletones_core.reconstructions.stage import TOTAL_STAGE_WEIGHT, ReconstructionStage
+from sampletones_shared.exceptions import OperationCanceled
+from sampletones_shared.utils.arrays import clamp
+
+STAGE_BEGUN: Final[int] = 0
+WHOLE_STAGE: Final[int] = 1
+
+PREPARATIONS: Final[int] = 3
+RECORDINGS_LOADED: Final[int] = 1
+FRAMES_PREPARED: Final[int] = 2
+
+NOTHING_DONE: Final[float] = 0.0
+WHOLE_RUN: Final[float] = 1.0
+
+
+@dataclass(frozen=True)
+class ReconstructionProgress:
+ """How far one stage of a reconstruction has come.
+
+ Attributes:
+ stage: The work the run is in the middle of, which names the unit the counts are in.
+ completed: What the stage has reached so far.
+ total: What the stage counts up to; a stage announcing its arrival alone counts to one.
+ """
+
+ stage: ReconstructionStage
+ completed: int
+ total: int
+
+ @property
+ def fraction(self) -> float:
+ """How much of the whole reconstruction stands finished, the stage weighed by its share.
+
+ The stages a run passes through count in units of their own, so a reading that spans them
+ all is each stage's own progress taken through the weight it carries. The weights are
+ divided once, here, which is what lets the last stage of a run arrive exactly at its end,
+ and the reading stays within the run it describes.
+ """
+ if self.total <= 0:
+ return self.stage.offset
+
+ covered = self.completed / self.total
+ reached = (self.stage.preceding_weight + self.stage.weight * covered) / TOTAL_STAGE_WEIGHT
+ return clamp(reached, NOTHING_DONE, WHOLE_RUN)
+
+
+ReconstructionReporter = Callable[[ReconstructionProgress], bool]
+
+
+def announce(
+ report: ReconstructionReporter,
+ stage: ReconstructionStage,
+ completed: int,
+ total: int,
+) -> None:
+ """Tells a reporter how far a stage has come, and unwinds the run it withdraws.
+
+ Args:
+ report: Hears the stage and answers whether the run goes on.
+ stage: The work the run is in the middle of.
+ completed: What the stage has reached so far.
+ total: What the stage counts up to.
+
+ Raises:
+ OperationCanceled: If the run is no longer wanted.
+ """
+ if not report(ReconstructionProgress(stage=stage, completed=completed, total=total)):
+ raise OperationCanceled(f"the reconstruction was withdrawn while {stage}")
diff --git a/src/sampletones_core/reconstructions/reconstruction/approximations.py b/src/sampletones_core/reconstructions/reconstruction/approximations.py
index 8956a60d7..55e791acc 100644
--- a/src/sampletones_core/reconstructions/reconstruction/approximations.py
+++ b/src/sampletones_core/reconstructions/reconstruction/approximations.py
@@ -1,7 +1,7 @@
import numpy as np
from pydantic import ConfigDict, Field, field_serializer
-from sampletones_core.constants.enums import GeneratorName
+from sampletones_core.constants.enums import ChannelName
from sampletones_core.data import DataModel
from sampletones_shared.types.data import SerializedData
from sampletones_shared.utils.serialization import serialize_array
@@ -10,8 +10,8 @@
class ApproximationsItem(DataModel):
model_config = ConfigDict(arbitrary_types_allowed=True, frozen=True)
- generator_name: GeneratorName = Field(..., description="Name of the generator")
- approximation: np.ndarray = Field(..., description="Audio approximation for the generator")
+ channel_name: ChannelName = Field(..., description="Name of the channel")
+ approximation: np.ndarray = Field(..., description="Audio approximation for the channel")
@field_serializer("approximation")
def serialize_approximation(self, approximation: np.ndarray) -> SerializedData:
diff --git a/src/sampletones_core/reconstructions/reconstruction/instructions.py b/src/sampletones_core/reconstructions/reconstruction/instructions.py
index 7fe2fd894..263e71113 100644
--- a/src/sampletones_core/reconstructions/reconstruction/instructions.py
+++ b/src/sampletones_core/reconstructions/reconstruction/instructions.py
@@ -4,7 +4,7 @@
from pydantic import ConfigDict, Field
-from sampletones_core.constants.enums import FeatureKey, GeneratorName
+from sampletones_core.constants.enums import ChannelName, FeatureKey
from sampletones_core.data import DataModel
from sampletones_core.features import resting_held_features, resting_reference
from sampletones_core.instructions import InstructionData, InstructionUnion
@@ -13,33 +13,33 @@
class InstructionsItem(DataModel):
model_config = ConfigDict(arbitrary_types_allowed=True, frozen=True)
- generator_name: GeneratorName = Field(
+ channel_name: ChannelName = Field(
...,
- description="Name of the generator",
+ description="Name of the channel",
)
instructions: List[InstructionData[InstructionUnion]] = Field(
...,
- description="List of instruction data for the generator",
+ description="List of instruction data for the channel",
)
initial_pitch: int = Field(
...,
- description="Reference pitch the generator's arpeggio envelope is measured against",
+ description="Reference pitch the channel's arpeggio envelope is measured against",
)
held_features: List[FeatureKey] = Field(
...,
- description="Dimensions the channel governs, keeping the value it holds while the generator sounds",
+ description="Dimensions the channel governs, keeping the value it holds while the channel sounds",
)
@classmethod
def create(
cls,
- generator_name: GeneratorName,
+ channel_name: ChannelName,
instructions: List[InstructionUnion],
initial_pitch: int,
held_features: Iterable[FeatureKey],
) -> InstructionsItem:
return InstructionsItem(
- generator_name=generator_name,
+ channel_name=channel_name,
instructions=[
InstructionData(
instruction_class=instruction.class_name(),
@@ -52,7 +52,7 @@ def create(
)
@classmethod
- def resting(cls, generator_name: GeneratorName) -> InstructionsItem:
+ def resting(cls, channel_name: ChannelName) -> InstructionsItem:
"""The stream a channel carries while it stands by, describing no frame.
A reconstruction holds one stream per channel, so a channel it leaves silent is
@@ -62,14 +62,14 @@ def resting(cls, generator_name: GeneratorName) -> InstructionsItem:
frame records and what an export of this stream reads back.
Args:
- generator_name: The channel the resting stream belongs to.
+ channel_name: The channel the resting stream belongs to.
Returns:
InstructionsItem: The stream of a channel that stands by.
"""
return cls.create(
- generator_name=generator_name,
+ channel_name=channel_name,
instructions=[],
- initial_pitch=resting_reference(generator_name),
- held_features=resting_held_features(generator_name),
+ initial_pitch=resting_reference(channel_name),
+ held_features=resting_held_features(channel_name),
)
diff --git a/src/sampletones_core/reconstructions/reconstruction/reconstruction.py b/src/sampletones_core/reconstructions/reconstruction/reconstruction.py
index b066f129b..da7a1ca46 100644
--- a/src/sampletones_core/reconstructions/reconstruction/reconstruction.py
+++ b/src/sampletones_core/reconstructions/reconstruction/reconstruction.py
@@ -18,20 +18,27 @@
from uuid import uuid4
import numpy as np
-from pydantic import ConfigDict, Field, ValidationError, field_serializer
+from pydantic import ConfigDict, Field, ValidationError, field_serializer, model_validator
+from sampletones_core.audio.mixing import align, common_length, mix
+from sampletones_core.compatibility.kind import ObjectKind
+from sampletones_core.compatibility.upgrade import upgrade_binary
from sampletones_core.configs import Config
-from sampletones_core.constants.enums import FeatureKey, GeneratorName
+from sampletones_core.constants.enums import ChannelName, FeatureKey
from sampletones_core.data import DataModel, Metadata, MetadataContract
from sampletones_core.exporters import (
- GENERATOR_NAME_TO_EXPORTER_MAP,
+ CHANNEL_TO_EXPORTER_MAP,
INSTRUCTION_TO_EXPORTER_MAP,
ExporterTypeUnion,
ExporterUnion,
Features,
)
-from sampletones_core.generators.maps import GENERATOR_CLASSES
+from sampletones_core.generators.render import render_channels
from sampletones_core.instructions import InstructionUnion
+from sampletones_core.reconstructions.reconstruction.approximations import ApproximationsItem
+from sampletones_core.reconstructions.reconstruction.instructions import InstructionsItem
+from sampletones_core.reconstructions.reconstruction.stems.data import StemsData
+from sampletones_core.reconstructions.reconstructor.state import ReconstructionState
from sampletones_shared.application import SAMPLETONES_RECONSTRUCTION_DATA_VERSION
from sampletones_shared.exceptions import (
IncompatibleReconstructionVersionError,
@@ -43,13 +50,8 @@
from sampletones_shared.types.callback import Callback
from sampletones_shared.types.data import SerializedData
from sampletones_shared.types.path import Pathlike
-from sampletones_shared.utils.arrays import pad
from sampletones_shared.utils.serialization import load_binary, serialize_array
-from ..reconstructor.state import ReconstructionState
-from .approximations import ApproximationsItem
-from .instructions import InstructionsItem
-
RECONSTRUCTION_DATA_CONTRACT: Final[MetadataContract] = MetadataContract(
label="Reconstruction data",
expected_version=SAMPLETONES_RECONSTRUCTION_DATA_VERSION,
@@ -68,9 +70,12 @@ class Reconstruction(DataModel):
...,
description="Unique identifier for the reconstruction",
)
- audio_filepath: Optional[Path] = Field(
+ audio_filepath: Tuple[Path, ...] = Field(
...,
- description="Location of the source audio; None marks a reconstruction detached from its local origin",
+ description=(
+ "Location of the source audio: one path per stems entry, in entry order, and "
+ "empty once detached from the local origin"
+ ),
)
config: Config = Field(
...,
@@ -83,23 +88,34 @@ class Reconstruction(DataModel):
)
approximations_data: List[ApproximationsItem] = Field(
...,
- description="Approximations per generator",
+ description="Approximations per channel",
)
instructions_data: List[InstructionsItem] = Field(
...,
- description="Instructions per generator",
+ description="Instructions per channel",
+ )
+ stems_data: StemsData = Field(
+ ...,
+ description="The stems setup and per-frame assignment recorded by the conversion",
)
coefficient: float = Field(
...,
description="Normalization coefficient used during reconstruction",
)
+ @model_validator(mode="after")
+ def _validate_source_stem_parallel(self) -> Self:
+ if self.audio_filepath and len(self.audio_filepath) != len(self.stems_data.config.entries):
+ raise ValueError("The recorded source paths number one per stems entry")
+
+ return self
+
@cached_property
- def approximations(self) -> Dict[GeneratorName, np.ndarray]:
- return {item.generator_name: item.approximation for item in self.approximations_data}
+ def approximations(self) -> Dict[ChannelName, np.ndarray]:
+ return {item.channel_name: item.approximation for item in self.approximations_data}
@cached_property
- def streams(self) -> Dict[GeneratorName, InstructionsItem]:
+ def streams(self) -> Dict[ChannelName, InstructionsItem]:
"""The instruction stream each channel carries, in channel order.
This is where the channel set is made whole: a channel the stored data names a stream
@@ -107,43 +123,43 @@ def streams(self) -> Dict[GeneratorName, InstructionsItem]:
carries. Every per-channel view reads from here, so each of them covers the four
channels however a reconstruction reached memory.
"""
- stored = {item.generator_name: item for item in self.instructions_data}
+ stored = {item.channel_name: item for item in self.instructions_data}
return {
- generator_name: stored.get(generator_name, InstructionsItem.resting(generator_name))
- for generator_name in GeneratorName.items()
+ channel_name: stored.get(channel_name, InstructionsItem.resting(channel_name))
+ for channel_name in ChannelName.items()
}
@cached_property
- def instructions(self) -> Dict[GeneratorName, List[InstructionUnion]]:
+ def instructions(self) -> Dict[ChannelName, List[InstructionUnion]]:
return {
- generator_name: [instruction.instruction for instruction in item.instructions]
- for generator_name, item in self.streams.items()
+ channel_name: [instruction.instruction for instruction in item.instructions]
+ for channel_name, item in self.streams.items()
}
@cached_property
- def initial_pitches(self) -> Dict[GeneratorName, int]:
- """The reference pitch each generator's arpeggio envelope is measured against."""
- return {generator_name: item.initial_pitch for generator_name, item in self.streams.items()}
+ def initial_pitches(self) -> Dict[ChannelName, int]:
+ """The reference pitch each channel's arpeggio envelope is measured against."""
+ return {channel_name: item.initial_pitch for channel_name, item in self.streams.items()}
@cached_property
- def held_features(self) -> Dict[GeneratorName, Tuple[FeatureKey, ...]]:
- """The dimensions each generator leaves to the channel.
+ def held_features(self) -> Dict[ChannelName, Tuple[FeatureKey, ...]]:
+ """The dimensions each channel's instrument writes for itself.
An instruction states every dimension of its frame, so which of them the instrument
itself writes is stated here: the rest are the channel's, and an export leaves their
envelopes empty for the player to fill from the value it holds.
"""
- return {generator_name: tuple(item.held_features) for generator_name, item in self.streams.items()}
+ return {channel_name: tuple(item.held_features) for channel_name, item in self.streams.items()}
@cached_property
- def playing_generators(self) -> Tuple[GeneratorName, ...]:
+ def playing_channels(self) -> Tuple[ChannelName, ...]:
"""The channels whose instruction stream describes a frame.
A reconstruction holds a stream for every channel, so this is what says which of them
play: the rest stand by, exporting nothing and costing nothing, while describing a
frame is what puts one in play.
"""
- return tuple(generator_name for generator_name, item in self.streams.items() if item.instructions)
+ return tuple(channel_name for channel_name, item in self.streams.items() if item.instructions)
@staticmethod
def _get_exporter_class(instruction: InstructionUnion) -> ExporterTypeUnion:
@@ -152,16 +168,16 @@ def _get_exporter_class(instruction: InstructionUnion) -> ExporterTypeUnion:
@classmethod
def _exporter_class(
cls,
- generator_name: GeneratorName,
+ channel_name: ChannelName,
instructions: List[InstructionUnion],
) -> ExporterTypeUnion:
"""The exporter a channel's stream is read through.
The instruction type names the exporter wherever the stream describes a frame; a
- channel standing by takes the exporter its generator name pairs with.
+ channel standing by takes the exporter its channel name pairs with.
"""
if not instructions:
- return GENERATOR_NAME_TO_EXPORTER_MAP[generator_name]
+ return CHANNEL_TO_EXPORTER_MAP[channel_name]
return cls._get_exporter_class(instructions[0])
@@ -178,35 +194,37 @@ def _derive_initial_pitch(cls, instructions: List[InstructionUnion]) -> int:
def create(
cls,
approximation: np.ndarray,
- approximations: Mapping[GeneratorName, np.ndarray],
- instructions: Mapping[GeneratorName, Sequence[InstructionUnion]],
+ approximations: Mapping[ChannelName, np.ndarray],
+ instructions: Mapping[ChannelName, Sequence[InstructionUnion]],
config: Config,
coefficient: float,
- audio_filepath: Path,
+ audio_filepath: Tuple[Path, ...],
+ stems_data: StemsData,
) -> Self:
approximation = np.nan_to_num(approximation, nan=0.0)
approximations_data: List[ApproximationsItem] = [
ApproximationsItem(
- generator_name=generator_name,
- approximation=approximations[generator_name],
+ channel_name=channel_name,
+ approximation=approximations[channel_name],
)
- for generator_name in GeneratorName.items()
- if generator_name in approximations
+ for channel_name in ChannelName.items()
+ if channel_name in approximations
]
instructions_data: List[InstructionsItem] = []
- for generator_name in GeneratorName.items():
- channel_instructions = list(instructions.get(generator_name, ()))
+ for channel_name in ChannelName.items():
+ channel_instructions = list(instructions.get(channel_name, ()))
if not channel_instructions:
- instructions_data.append(InstructionsItem.resting(generator_name))
+ instructions_data.append(InstructionsItem.resting(channel_name))
continue
+ exporter_class = cls._get_exporter_class(channel_instructions[0])
instructions_data.append(
InstructionsItem.create(
- generator_name=generator_name,
+ channel_name=channel_name,
instructions=channel_instructions,
initial_pitch=cls._derive_initial_pitch(channel_instructions),
- held_features=(),
+ held_features=exporter_class.unstated_features(channel_instructions), # type: ignore[arg-type]
)
)
@@ -215,6 +233,7 @@ def create(
approximation=approximation,
approximations_data=approximations_data,
instructions_data=instructions_data,
+ stems_data=stems_data,
config=config,
coefficient=coefficient,
audio_filepath=audio_filepath,
@@ -226,14 +245,19 @@ def from_state(
state: ReconstructionState,
config: Config,
coefficient: float,
- path: Path,
+ path: Tuple[Path, ...],
+ stems_data: StemsData,
) -> Optional[Self]:
- if any(len(approximation) == 0 for approximation in state.approximations.values()):
+ if all(len(approximation) == 0 for approximation in state.approximations.values()):
logger.warning(f"Reconstruction for file: {path} is empty")
return None
- approximations = {name: np.concatenate(state.approximations[name]) for name in state.approximations}
- approximation = cls._sum_approximations(list(approximations.values()))
+ approximations = {
+ name: np.concatenate(state.approximations[name])
+ for name in state.approximations
+ if state.approximations[name]
+ }
+ approximation = mix(list(approximations.values()))
return cls.create(
approximation=approximation,
@@ -242,17 +266,18 @@ def from_state(
config=config,
coefficient=coefficient,
audio_filepath=path,
+ stems_data=stems_data,
)
- def update_generator_data(
+ def update_channel_data(
self,
- generator_name: GeneratorName,
+ channel_name: ChannelName,
instructions: List[InstructionUnion],
partial_approximation: np.ndarray,
initial_pitch: int,
held_features: Iterable[FeatureKey],
) -> None:
- """Replaces one generator's instructions, audio, reference pitch, and held dimensions.
+ """Replaces one channel's instructions, audio, reference pitch, and held dimensions.
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. The held
@@ -261,12 +286,14 @@ def update_generator_data(
The channel keeps its place among the streams however the edit leaves it, so one
cleared of every frame stands by and stays editable. Its rendered audio lasts as
- long as it carries samples, which keeps silence out of the stored waveforms.
+ long as it carries samples, which keeps silence out of the stored waveforms. The
+ edited channel leaves the stems record: the edit re-derives the stream, so the
+ conversion's per-frame ownership no longer applies to it.
"""
partial_approximation = np.trim_zeros(partial_approximation, trim="b")
- rendered = {name: audio for name, audio in self.approximations.items() if name != generator_name}
+ rendered = {name: audio for name, audio in self.approximations.items() if name != channel_name}
if partial_approximation.size:
- rendered[generator_name] = partial_approximation
+ rendered[channel_name] = partial_approximation
max_length = max(
(len(np.trim_zeros(audio, trim="b")) for audio in rendered.values()),
@@ -276,38 +303,42 @@ def update_generator_data(
self.approximations_data = self._build_approximations_data(rendered, max_length)
streams = dict(self.streams)
- streams[generator_name] = InstructionsItem.create(
- generator_name=generator_name,
+ streams[channel_name] = InstructionsItem.create(
+ channel_name=channel_name,
instructions=instructions,
initial_pitch=initial_pitch,
held_features=held_features,
)
- self.instructions_data = [streams[name] for name in GeneratorName.items()]
+ self.instructions_data = [streams[name] for name in ChannelName.items()]
+ self.stems_data = StemsData(
+ config=self.stems_data.config,
+ assignments=[item for item in self.stems_data.assignments if item.channel_name != channel_name],
+ )
self._invalidate_derived_caches(self)
- self.approximation = self._sum_approximations([item.approximation for item in self.approximations_data])
+ self.approximation = mix([item.approximation for item in self.approximations_data])
- def get_generator_instructions(
+ def get_channel_instructions(
self,
- generator_name: GeneratorName,
+ channel_name: ChannelName,
) -> List[InstructionUnion]:
- return self.instructions[generator_name]
+ return self.instructions[channel_name]
def detach_source(self) -> None:
"""Drops the local source-audio location so the reconstruction becomes self-contained.
Embedding a reconstruction in a project makes it part of a shareable artifact, where an
- absolute path to the author's machine carries no meaning. Clearing ``audio_filepath`` keeps
- the reconstruction — its approximation and instructions — intact while removing the local
- origin, so a saved project stays portable.
+ absolute path to the author's machine carries no meaning. Emptying ``audio_filepath``
+ keeps the reconstruction — its approximation and instructions — intact while removing the
+ local origin, so a saved project stays portable.
"""
- self.audio_filepath = None
+ self.audio_filepath = ()
def with_nes_frequency(self, nes_frequency: int) -> Reconstruction:
"""Returns a copy retuned to ``nes_frequency`` by re-rendering its audio.
A project runs every embedded sample at one change rate, so a reconstruction joining a
project adopts that rate. The frozen ``config`` is rebuilt at the new rate and each
- generator's approximation is re-synthesized from its stored instructions at the matching
+ channel's approximation is re-synthesized from its stored instructions at the matching
frame length, re-timing the audio; the instructions and coefficient carry over. The
original instance is returned when it already runs at ``nes_frequency``.
"""
@@ -317,33 +348,20 @@ def with_nes_frequency(self, nes_frequency: int) -> Reconstruction:
return self._resynthesized(self.config.with_library(nes_frequency=nes_frequency))
def _resynthesized(self, config: Config) -> Reconstruction:
- """Re-renders every generator's approximation from its instructions at ``config``.
+ """Re-renders every channel's approximation from its instructions at ``config``.
Each instruction spans ``config.frame_length`` samples, so re-rendering at a new frame
length re-times the audio. The channels describing frames are rendered, padded to a
- common length and summed; the mixer weight is baked into each generator's output, so a
+ common length and summed; the mixer weight is baked into each channel's output, so a
plain sum reproduces the stored approximation shape. Drive is left at unity to match the
regeneration path.
"""
- rendered: Dict[GeneratorName, np.ndarray] = {}
- for generator_name, instructions in self.instructions.items():
- if not instructions:
- continue
-
- generator = GENERATOR_CLASSES[generator_name](
- config,
- generator_name.value,
- )
- rendered[generator_name] = np.concatenate(
- [generator(instruction, save=True) for instruction in instructions] # type: ignore[arg-type]
- )
-
- max_length = max((len(audio) for audio in rendered.values()), default=0)
+ rendered = render_channels(self.instructions, config)
approximations_data = self._build_approximations_data(
rendered,
- max_length,
+ common_length(rendered.values()),
)
- approximation = self._sum_approximations([item.approximation for item in approximations_data])
+ approximation = mix([item.approximation for item in approximations_data])
retuned: Reconstruction = self.model_copy(
update={
@@ -355,47 +373,35 @@ def _resynthesized(self, config: Config) -> Reconstruction:
self._invalidate_derived_caches(retuned)
return retuned
- @staticmethod
- def _sum_approximations(arrays: Sequence[np.ndarray]) -> np.ndarray:
- """Mixes equal-length per-generator approximations into one waveform.
-
- Returns an empty float array when no generator contributes, so a reconstruction with no
- rendered audio still carries a valid approximation.
- """
- if not arrays:
- return np.zeros(0, dtype=np.float32)
-
- mixed: np.ndarray = np.sum(np.array(arrays), axis=0).astype(np.float32)
- return mixed
-
@staticmethod
def _build_approximations_data(
- rendered: Mapping[GeneratorName, np.ndarray],
+ rendered: Mapping[ChannelName, np.ndarray],
length: int,
) -> List[ApproximationsItem]:
- """Pads each rendered channel's audio to ``length``, in channel order.
+ """Brings each rendered channel's audio to ``length``, in channel order.
- A shared length lets the per-generator arrays stack and sum into the mixed approximation,
+ A shared length lets the per-channel arrays stack and sum into the mixed approximation,
and a fixed order keeps a stored reconstruction reading the same however an edit reached it.
"""
+ names = [channel_name for channel_name in ChannelName.items() if channel_name in rendered]
+ aligned = align([rendered[channel_name] for channel_name in names], length)
return [
ApproximationsItem(
- generator_name=generator_name,
- approximation=pad(rendered[generator_name], 0, length),
+ channel_name=channel_name,
+ approximation=audio,
)
- for generator_name in GeneratorName.items()
- if generator_name in rendered
+ for channel_name, audio in zip(names, aligned)
]
@staticmethod
def _invalidate_derived_caches(reconstruction: Reconstruction) -> None:
- """Drops the memoized per-generator views so they recompute from their backing data."""
+ """Drops the memoized per-channel views so they recompute from their backing data."""
reconstruction.__dict__.pop("approximations", None)
reconstruction.__dict__.pop("streams", None)
reconstruction.__dict__.pop("instructions", None)
reconstruction.__dict__.pop("initial_pitches", None)
reconstruction.__dict__.pop("held_features", None)
- reconstruction.__dict__.pop("playing_generators", None)
+ reconstruction.__dict__.pop("playing_channels", None)
@classmethod
def load(cls, path: Pathlike, fast: bool = True) -> Reconstruction:
@@ -416,6 +422,7 @@ def deserialize_data(
fast: bool = True,
) -> Reconstruction:
try:
+ binary = upgrade_binary(ObjectKind.RECONSTRUCTION, binary)
return cls.deserialize(binary, validation=validation, fast=fast)
except (ValidationError, TypeError, ValueError, struct.error, IndexError) as exception:
raise InvalidReconstructionValuesError(
@@ -457,17 +464,17 @@ def _validate_instructions(
f"with exporter {exporter_class.__name__}"
)
- def export(self) -> Dict[GeneratorName, Features]:
+ def export(self) -> Dict[ChannelName, Features]:
"""The envelopes each channel exports, one entry per channel the reconstruction holds.
A channel standing by describes no frame, so its envelopes come back empty and every
reader tells it from a channel that plays by :attr:`Features.has_frames`.
Returns:
- Dict[GeneratorName, Features]: The envelope representation of each channel.
+ Dict[ChannelName, Features]: The envelope representation of each channel.
"""
- features: Dict[GeneratorName, Features] = {}
- for name in GeneratorName.items():
+ features: Dict[ChannelName, Features] = {}
+ for name in ChannelName.items():
instructions = self.instructions[name]
exporter_class = self._exporter_class(name, instructions)
exporter: ExporterUnion = exporter_class()
@@ -493,10 +500,7 @@ def _serialize_approximation(
@field_serializer("audio_filepath")
def _serialize_audio_filepath(
self,
- audio_filepath: Optional[Path],
+ audio_filepath: Tuple[Path, ...],
_info: Any,
- ) -> Optional[str]:
- if audio_filepath is None:
- return None
-
- return str(audio_filepath)
+ ) -> List[str]:
+ return [str(path) for path in audio_filepath]
diff --git a/src/sampletones_core/reconstructions/reconstruction/stems/__init__.py b/src/sampletones_core/reconstructions/reconstruction/stems/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/src/sampletones_core/reconstructions/reconstruction/stems/channel_assignment.py b/src/sampletones_core/reconstructions/reconstruction/stems/channel_assignment.py
new file mode 100644
index 000000000..2d1acab32
--- /dev/null
+++ b/src/sampletones_core/reconstructions/reconstruction/stems/channel_assignment.py
@@ -0,0 +1,19 @@
+from typing import List
+
+from pydantic import ConfigDict, Field
+
+from sampletones_core.constants.enums import ChannelName
+from sampletones_core.data import DataModel
+
+
+class ChannelAssignment(DataModel):
+ model_config = ConfigDict(extra="forbid", frozen=True)
+
+ channel_name: ChannelName = Field(
+ ...,
+ description="The channel whose frames are assigned",
+ )
+ stem_ids: List[int] = Field(
+ ...,
+ description="The stem holding the channel in each frame",
+ )
diff --git a/src/sampletones_core/reconstructions/reconstruction/stems/data.py b/src/sampletones_core/reconstructions/reconstruction/stems/data.py
new file mode 100644
index 000000000..11ed4c2e8
--- /dev/null
+++ b/src/sampletones_core/reconstructions/reconstruction/stems/data.py
@@ -0,0 +1,47 @@
+from __future__ import annotations
+
+from functools import cached_property
+from typing import Dict, List
+
+from pydantic import ConfigDict, Field
+
+from sampletones_core.constants.algorithm import ALL_STEMS_CHANNEL_CAP
+from sampletones_core.constants.enums import ChannelName
+from sampletones_core.data import DataModel
+from sampletones_core.reconstructions.reconstruction.stems.channel_assignment import ChannelAssignment
+from sampletones_core.reconstructions.reconstructor.stems.configs.config import StemsConfig
+
+
+class StemsData(DataModel):
+ model_config = ConfigDict(extra="forbid", frozen=True)
+
+ config: StemsConfig = Field(
+ ...,
+ description="The stems setup the assignment was made under",
+ )
+ assignments: List[ChannelAssignment] = Field(
+ ...,
+ description="Per channel, the stem holding each frame",
+ )
+
+ @classmethod
+ def single_entry(
+ cls,
+ channels: List[ChannelName],
+ assignments: List[ChannelAssignment],
+ *,
+ channel_cap: int = ALL_STEMS_CHANNEL_CAP,
+ ) -> StemsData:
+ """The record of one stem covering ``channels`` under ``channel_cap``."""
+ return cls(
+ config=StemsConfig.single_entry(
+ channels,
+ channel_cap=channel_cap,
+ ),
+ assignments=assignments,
+ )
+
+ @cached_property
+ def assignments_by_channel(self) -> Dict[ChannelName, List[int]]:
+ """The per-frame stem ids each channel carries, keyed by channel."""
+ return {item.channel_name: item.stem_ids for item in self.assignments}
diff --git a/src/sampletones_core/reconstructions/reconstruction/stems/filter.py b/src/sampletones_core/reconstructions/reconstruction/stems/filter.py
new file mode 100644
index 000000000..f7903c8d1
--- /dev/null
+++ b/src/sampletones_core/reconstructions/reconstruction/stems/filter.py
@@ -0,0 +1,35 @@
+from typing import Dict, Mapping
+
+import numpy as np
+
+from sampletones_core.constants.enums import ChannelName
+from sampletones_core.reconstructions.reconstruction.stems.data import StemsData
+from sampletones_core.reconstructions.reconstruction.stems.selection import StemSelection
+
+
+def filter_approximations(
+ stems_data: StemsData,
+ approximations: Mapping[ChannelName, np.ndarray],
+ selection: StemSelection,
+ frame_length: int,
+) -> Dict[ChannelName, np.ndarray]:
+ """Returns the per-channel approximations with the unselected stems' frames zeroed.
+
+ Stem id ``i`` names frame ``i`` of its channel — the same index the channel's stored
+ approximation slices hold — so a frame whose stem is unselected on that channel becomes
+ silence while every other frame keeps its samples. The selection answers each channel on
+ its own, so one recording is heard on a channel and stays quiet on the next. The arrays
+ keep their lengths, which is what aligns a filtered mix with the unfiltered one sample for
+ sample. The mask covers the frames the stored array holds; samples past the last recorded
+ frame keep their values.
+ """
+ filtered: Dict[ChannelName, np.ndarray] = {}
+ for channel, stem_ids in stems_data.assignments_by_channel.items():
+ approximation = approximations[channel]
+ keep = np.isin(np.array(stem_ids, dtype=int), list(selection.stems_for(channel)))
+ keep_samples = np.repeat(keep, frame_length)
+ masked = np.array(approximation, copy=True)
+ masked[~keep_samples[: len(masked)]] = 0
+ filtered[channel] = masked
+
+ return filtered
diff --git a/src/sampletones_core/reconstructions/reconstruction/stems/removal.py b/src/sampletones_core/reconstructions/reconstruction/stems/removal.py
new file mode 100644
index 000000000..a2e1ebaa8
--- /dev/null
+++ b/src/sampletones_core/reconstructions/reconstruction/stems/removal.py
@@ -0,0 +1,205 @@
+from pathlib import Path
+from typing import Dict, FrozenSet, List, Sequence, Tuple
+
+import numpy as np
+
+from sampletones_core.audio.mixing import mix
+from sampletones_core.constants.algorithm import RESTING_STEM_ID
+from sampletones_core.constants.enums import ChannelName
+from sampletones_core.instructions import InstructionUnion
+from sampletones_core.reconstructions.reconstruction.approximations import ApproximationsItem
+from sampletones_core.reconstructions.reconstruction.instructions import InstructionsItem
+from sampletones_core.reconstructions.reconstruction.reconstruction import Reconstruction
+from sampletones_core.reconstructions.reconstruction.stems.channel_assignment import ChannelAssignment
+from sampletones_core.reconstructions.reconstruction.stems.data import StemsData
+from sampletones_core.reconstructions.reconstructor.stems.configs.config import StemsConfig
+from sampletones_core.reconstructions.reconstructor.stems.configs.hierarchy import StemsHierarchy
+
+
+def without_stem(reconstruction: Reconstruction, stem_id: int) -> Reconstruction:
+ """The reconstruction with one recording taken out, the frames it held left resting.
+
+ A released frame states silence, zeroes the samples it rendered and takes
+ ``RESTING_STEM_ID`` in the assignment, which is the shape a capped run already records for
+ a frame every stem passed over. A channel the removal empties stands by, describing no
+ frame at all. Everything the removal leaves alone stands as it was — the frames of the
+ recordings that stay, sample for sample, and the stream of a channel that already rested
+ throughout — and the mixed approximation is summed afresh from what remains.
+
+ The entry leaves the recorded setup, taking its level along once that level holds nothing
+ else, and its source path leaves ``audio_filepath`` from the position it stood at. The
+ identifier, configuration, coefficient and metadata carry over, so the result is the same
+ document holding one recording fewer.
+
+ Args:
+ reconstruction: The reconstruction the recording is taken out of.
+ stem_id: The stems entry to remove.
+
+ Returns:
+ Reconstruction: A fresh reconstruction holding the recordings that stay.
+
+ Raises:
+ ValueError: If ``stem_id`` names no recorded entry, or names the last one standing.
+ """
+ stems_data = reconstruction.stems_data
+ config = stems_data.config
+ if stem_id not in config.entries_by_id:
+ raise ValueError(f"Stem {stem_id} names no entry of the recorded setup")
+
+ if len(config.entries) == 1:
+ raise ValueError("A reconstruction holds at least one stem")
+
+ released = {item.channel_name: [held == stem_id for held in item.stem_ids] for item in stems_data.assignments}
+ assignments = [_released_assignment(item, released[item.channel_name]) for item in stems_data.assignments]
+ resting = _emptied_channels(assignments, released)
+
+ streams = _released_streams(reconstruction, released, resting)
+ approximations_data = [
+ ApproximationsItem(
+ channel_name=item.channel_name,
+ approximation=_released_audio(item, released, resting, reconstruction.config.frame_length),
+ )
+ for item in reconstruction.approximations_data
+ ]
+
+ return Reconstruction(
+ metadata=reconstruction.metadata,
+ id=reconstruction.id,
+ audio_filepath=_paths_without(reconstruction.audio_filepath, _position_of(config, stem_id)),
+ config=reconstruction.config,
+ approximation=mix([item.approximation for item in approximations_data]),
+ approximations_data=approximations_data,
+ instructions_data=[streams[channel_name] for channel_name in ChannelName.items()],
+ stems_data=StemsData(
+ config=_config_without(config, stem_id),
+ assignments=assignments,
+ ),
+ coefficient=reconstruction.coefficient,
+ )
+
+
+def _position_of(config: StemsConfig, stem_id: int) -> int:
+ """Where the entry stands among the recorded ones, which is the source path it pairs with."""
+ return [entry.id for entry in config.entries].index(stem_id)
+
+
+def _config_without(config: StemsConfig, stem_id: int) -> StemsConfig:
+ """The recorded setup with one entry gone, and the level it emptied gone along with it."""
+ levels = [[held for held in level if held != stem_id] for level in config.hierarchy.levels]
+ return StemsConfig(
+ entries=[entry for entry in config.entries if entry.id != stem_id],
+ hierarchy=StemsHierarchy(
+ levels=[level for level in levels if level],
+ mode=config.hierarchy.mode,
+ ),
+ channel_cap=config.channel_cap,
+ )
+
+
+def _paths_without(paths: Tuple[Path, ...], position: int) -> Tuple[Path, ...]:
+ """The recorded source paths with the one at ``position`` gone, empty staying empty."""
+ return tuple(path for index, path in enumerate(paths) if index != position)
+
+
+def _released_assignment(item: ChannelAssignment, released: Sequence[bool]) -> ChannelAssignment:
+ """The channel's per-frame ownership with each released frame resting."""
+ return ChannelAssignment(
+ channel_name=item.channel_name,
+ stem_ids=[RESTING_STEM_ID if frame_released else held for held, frame_released in zip(item.stem_ids, released)],
+ )
+
+
+def _emptied_channels(
+ assignments: Sequence[ChannelAssignment],
+ released: Dict[ChannelName, List[bool]],
+) -> FrozenSet[ChannelName]:
+ """The channels the removal leaves resting through every frame.
+
+ A channel reaches this state by losing frames it held, so one that already rested
+ throughout is left as it stood.
+ """
+ return frozenset(
+ item.channel_name
+ for item in assignments
+ if any(released[item.channel_name]) and all(held == RESTING_STEM_ID for held in item.stem_ids)
+ )
+
+
+def _released_streams(
+ reconstruction: Reconstruction,
+ released: Dict[ChannelName, List[bool]],
+ resting: FrozenSet[ChannelName],
+) -> Dict[ChannelName, InstructionsItem]:
+ """Every channel's stream as the removal leaves it, keyed by channel.
+
+ A channel the assignment says nothing about keeps its stream whole: an edit already
+ re-derived it, so the conversion's per-frame ownership stopped applying to it.
+ """
+ streams: Dict[ChannelName, InstructionsItem] = {}
+ for channel_name, stream in reconstruction.streams.items():
+ if channel_name in resting:
+ streams[channel_name] = InstructionsItem.resting(channel_name)
+ elif channel_name in released:
+ streams[channel_name] = _released_stream(stream, released[channel_name])
+ else:
+ streams[channel_name] = stream
+
+ return streams
+
+
+def _released_stream(stream: InstructionsItem, released: Sequence[bool]) -> InstructionsItem:
+ """The channel's stream with each released frame stating silence.
+
+ The silent instruction takes the type the stream already carries, which is the type the
+ channel is read through, so the stream stays one exporter's throughout.
+ """
+ instructions = [data.instruction for data in stream.instructions]
+ if not instructions:
+ return stream
+
+ null: InstructionUnion = type(instructions[0]).null_instruction()
+ return InstructionsItem.create(
+ channel_name=stream.channel_name,
+ instructions=[
+ null if index < len(released) and released[index] else instruction
+ for index, instruction in enumerate(instructions)
+ ],
+ initial_pitch=stream.initial_pitch,
+ held_features=stream.held_features,
+ )
+
+
+def _released_audio(
+ item: ApproximationsItem,
+ released: Dict[ChannelName, List[bool]],
+ resting: FrozenSet[ChannelName],
+ frame_length: int,
+) -> np.ndarray:
+ """The channel's rendered audio with the released frames silent.
+
+ A channel the removal empties comes back silent over its whole span, which keeps its
+ length among the stored waveforms while it sounds nothing.
+ """
+ if item.channel_name in resting:
+ return np.zeros_like(item.approximation)
+
+ if item.channel_name not in released:
+ return item.approximation
+
+ return _silenced(item.approximation, released[item.channel_name], frame_length)
+
+
+def _silenced(approximation: np.ndarray, released: Sequence[bool], frame_length: int) -> np.ndarray:
+ """The waveform with the samples of each released frame zeroed.
+
+ Frame ``i`` renders samples ``i * frame_length`` onward, so the per-frame flags spread
+ across the samples they cover. Samples past the last recorded frame keep their values.
+ """
+ silent = np.repeat(np.array(released, dtype=bool), frame_length)
+ span = min(len(silent), len(approximation))
+ mask = np.zeros(len(approximation), dtype=bool)
+ mask[:span] = silent[:span]
+
+ quiet = np.array(approximation, copy=True)
+ quiet[mask] = 0
+ return quiet
diff --git a/src/sampletones_core/reconstructions/reconstruction/stems/selection.py b/src/sampletones_core/reconstructions/reconstruction/stems/selection.py
new file mode 100644
index 000000000..bf2043bf7
--- /dev/null
+++ b/src/sampletones_core/reconstructions/reconstruction/stems/selection.py
@@ -0,0 +1,35 @@
+from typing import AbstractSet, Dict, FrozenSet, Iterable, Self
+
+from pydantic import BaseModel, ConfigDict
+
+from sampletones_core.constants.enums import ChannelName
+
+
+class StemSelection(BaseModel):
+ """Which stems a reader is listening to, channel by channel.
+
+ A stem is heard on a channel once its id stands in that channel's set, so one recording
+ carries a channel while another keeps quiet on it. What the recordings are mixed into is
+ drawn from the stems heard anywhere, which is what :meth:`any_channel` answers.
+ """
+
+ model_config = ConfigDict(extra="forbid", frozen=True)
+
+ channels: Dict[ChannelName, FrozenSet[int]]
+
+ @classmethod
+ def everywhere(
+ cls,
+ stem_ids: AbstractSet[int],
+ channels: Iterable[ChannelName],
+ ) -> Self:
+ """The selection hearing every stem of ``stem_ids`` on every channel of ``channels``."""
+ return cls(channels={channel: frozenset(stem_ids) for channel in channels})
+
+ def stems_for(self, channel: ChannelName) -> FrozenSet[int]:
+ """The stems heard on one channel, empty where the channel names none."""
+ return self.channels.get(channel, frozenset())
+
+ def any_channel(self) -> FrozenSet[int]:
+ """The stems heard on at least one channel."""
+ return frozenset().union(*self.channels.values()) if self.channels else frozenset()
diff --git a/src/sampletones_core/reconstructions/reconstructor/approximation.py b/src/sampletones_core/reconstructions/reconstructor/approximation.py
deleted file mode 100644
index b19b41358..000000000
--- a/src/sampletones_core/reconstructions/reconstructor/approximation.py
+++ /dev/null
@@ -1,13 +0,0 @@
-from pydantic import BaseModel, ConfigDict
-
-from sampletones_core.constants.enums import GeneratorName
-from sampletones_core.fft import Fragment
-from sampletones_core.instructions import InstructionUnion
-
-
-class ApproximationData(BaseModel):
- model_config = ConfigDict(arbitrary_types_allowed=True, frozen=True)
-
- generator_name: GeneratorName
- approximation: Fragment
- instruction: InstructionUnion
diff --git a/src/sampletones_core/reconstructions/reconstructor/decoder/__init__.py b/src/sampletones_core/reconstructions/reconstructor/decoder/__init__.py
new file mode 100644
index 000000000..d3bff8a82
--- /dev/null
+++ b/src/sampletones_core/reconstructions/reconstructor/decoder/__init__.py
@@ -0,0 +1,22 @@
+from typing import Dict, Type
+
+from sampletones_core.constants.enums import SelectorName
+
+from .base import ChannelLattice, Decoder, Lattices, Streams
+from .greedy import GreedyDecoder
+from .viterbi import ViterbiDecoder
+
+DECODERS: Dict[SelectorName, Type[Decoder]] = {
+ SelectorName.GREEDY: GreedyDecoder,
+ SelectorName.VITERBI: ViterbiDecoder,
+}
+
+__all__ = [
+ "DECODERS",
+ "ChannelLattice",
+ "Decoder",
+ "GreedyDecoder",
+ "Lattices",
+ "Streams",
+ "ViterbiDecoder",
+]
diff --git a/src/sampletones_core/reconstructions/reconstructor/decoder/base.py b/src/sampletones_core/reconstructions/reconstructor/decoder/base.py
new file mode 100644
index 000000000..ed1b35b2e
--- /dev/null
+++ b/src/sampletones_core/reconstructions/reconstructor/decoder/base.py
@@ -0,0 +1,38 @@
+from abc import ABC, abstractmethod
+from typing import Dict, List
+
+from sampletones_core.configs import Config
+from sampletones_core.constants.enums import ChannelName
+
+from ..matching import Column, ScoredCandidate
+
+ChannelLattice = List[Column]
+Lattices = Dict[ChannelName, ChannelLattice]
+Streams = Dict[ChannelName, List[ScoredCandidate]]
+
+
+class Decoder(ABC):
+ """
+ Chooses what each channel plays across the frames it was given.
+
+ A frame assignment hands every channel in play one column per frame: the candidates that
+ channel may sound there, best first. A decoder reads those columns and answers one candidate
+ per frame per channel, which is the instruction stream the reconstruction records. Ownership
+ is settled before a decoder sees the frames, so a decoder decides the stream alone.
+
+ ``lattice_width`` states how many alternatives per frame the decoder reads, and the
+ assignment builds columns to exactly that width, so a decoder that reads one candidate
+ per frame costs the assignment nothing beyond the pick it already made.
+ """
+
+ def __init__(self, config: Config) -> None:
+ self.config = config
+
+ @property
+ @abstractmethod
+ def lattice_width(self) -> int:
+ """How many candidates per frame the decoder chooses among."""
+
+ @abstractmethod
+ def decode(self, lattices: Lattices) -> Streams:
+ """One candidate per frame for every channel, in frame order."""
diff --git a/src/sampletones_core/reconstructions/reconstructor/decoder/greedy.py b/src/sampletones_core/reconstructions/reconstructor/decoder/greedy.py
new file mode 100644
index 000000000..7a0843461
--- /dev/null
+++ b/src/sampletones_core/reconstructions/reconstructor/decoder/greedy.py
@@ -0,0 +1,19 @@
+from sampletones_core.constants.algorithm import SINGLE_STATE_LATTICE_WIDTH
+
+from .base import Decoder, Lattices, Streams
+
+
+class GreedyDecoder(Decoder):
+ """
+ Plays each frame's best candidate, so every frame stands on its own.
+
+ Reading one candidate per frame makes the frame's own cost the whole decision, which is
+ the classic behavior: what the matching ranked first is what the channel plays.
+ """
+
+ @property
+ def lattice_width(self) -> int:
+ return SINGLE_STATE_LATTICE_WIDTH
+
+ def decode(self, lattices: Lattices) -> Streams:
+ return {channel_name: [column[0] for column in frames] for channel_name, frames in lattices.items()}
diff --git a/src/sampletones_core/reconstructions/reconstructor/selector/viterbi.py b/src/sampletones_core/reconstructions/reconstructor/decoder/viterbi.py
similarity index 51%
rename from src/sampletones_core/reconstructions/reconstructor/selector/viterbi.py
rename to src/sampletones_core/reconstructions/reconstructor/decoder/viterbi.py
index 8f4d76d94..47f7ed721 100644
--- a/src/sampletones_core/reconstructions/reconstructor/selector/viterbi.py
+++ b/src/sampletones_core/reconstructions/reconstructor/decoder/viterbi.py
@@ -1,101 +1,43 @@
import itertools
-from typing import Dict, List, Tuple
+from typing import List, Tuple
import numpy as np
from sampletones_core.configs import Config
-from sampletones_core.constants.enums import GeneratorName
-from sampletones_core.fft import Fragment, FragmentedAudio, Window
-from sampletones_core.fft.features import FeatureExtractor
-from sampletones_core.generators import GeneratorUnion
from sampletones_core.instructions import InstructionUnion
-from ..approximation import ApproximationData
-from ..candidates import CandidateProvider
-from ..phase import PhaseAligner
-from ..scorer import Scorer
-from .base import ScoredCandidate, Selector
+from ..matching import ScoredCandidate
+from .base import ChannelLattice, Decoder, Lattices, Streams
-ChannelLattice = List[List[ScoredCandidate]]
-FrameCandidates = Dict[GeneratorName, List[ScoredCandidate]]
+class ViterbiDecoder(Decoder):
+ """
+ Plays the cheapest path through each channel's frames, cost and continuity together.
-class ViterbiSelector(Selector):
- def __init__(
- self,
- config: Config,
- window: Window,
- generators: Dict[GeneratorName, GeneratorUnion],
- scorer: Scorer,
- candidate_provider: CandidateProvider,
- phase_aligner: PhaseAligner,
- feature_extractor: FeatureExtractor,
- ) -> None:
- super().__init__(
- config,
- window,
- generators,
- scorer,
- candidate_provider,
- phase_aligner,
- feature_extractor,
- )
+ A frame's candidates are weighed against the frames around them: on top of each
+ candidate's own cost, moving between two candidates costs what changes between their
+ instructions — pitch, volume, timbre, and turning a channel on or off. The path that
+ minimizes the total holds a steady note where per-frame costs alone would flicker.
+ """
+
+ def __init__(self, config: Config) -> None:
+ super().__init__(config)
decoder = config.generation.decoder
self.pitch_weight = decoder.pitch_weight
self.volume_weight = decoder.volume_weight
self.timbre_weight = decoder.timbre_weight
self.on_off_weight = decoder.on_off_weight
- def select(
- self,
- fragmented_audio: FragmentedAudio,
- fragment_ids: List[int],
- ) -> Dict[int, Dict[GeneratorName, ApproximationData]]:
- lattices = self._build_lattices(fragmented_audio, fragment_ids)
- return self._decode_lattices(lattices, fragment_ids)
-
- def _build_lattices(
- self,
- fragmented_audio: FragmentedAudio,
- fragment_ids: List[int],
- ) -> Dict[GeneratorName, ChannelLattice]:
- lattices: Dict[GeneratorName, ChannelLattice] = {name: [] for name in self.generators}
- for fragment_id in fragment_ids:
- for generator_name, states in self._frame_candidates(fragmented_audio[fragment_id]).items():
- lattices[generator_name].append(states)
+ @property
+ def lattice_width(self) -> int:
+ return self.config.generation.decoder.top_k
- return lattices
+ def decode(self, lattices: Lattices) -> Streams:
+ return {channel_name: self._decode_channel(frames) for channel_name, frames in lattices.items()}
- def _decode_lattices(
- self,
- lattices: Dict[GeneratorName, ChannelLattice],
- fragment_ids: List[int],
- ) -> Dict[int, Dict[GeneratorName, ApproximationData]]:
- result: Dict[int, Dict[GeneratorName, ApproximationData]] = {fragment_id: {} for fragment_id in fragment_ids}
- for generator_name, frames in lattices.items():
- path = self._decode(frames)
- for position, fragment_id in enumerate(fragment_ids):
- state = frames[position][path[position]]
- result[fragment_id][generator_name] = ApproximationData(
- generator_name=generator_name,
- approximation=state.approximation,
- instruction=state.instruction,
- )
-
- return result
-
- def _frame_candidates(self, fragment: Fragment) -> FrameCandidates:
- candidates: FrameCandidates = {}
- residual = fragment
- for generator_name, generator in self.generators.items():
- channel_states = self._channel_candidates(residual, generator)
- candidates[generator_name] = channel_states
- residual = self.feature_extractor.subtract(residual, channel_states[0].approximation)
-
- return candidates
-
- def _channel_candidates(self, residual: Fragment, generator: GeneratorUnion) -> List[ScoredCandidate]:
- return self._score_candidates(residual, {generator.class_name(): generator})
+ def _decode_channel(self, frames: ChannelLattice) -> List[ScoredCandidate]:
+ path = self._decode(frames)
+ return [frames[position][state] for position, state in enumerate(path)]
def _decode(self, frames: ChannelLattice) -> List[int]:
if not frames:
@@ -124,7 +66,7 @@ def _forward_pass(self, frames: ChannelLattice) -> Tuple[List[List[int]], List[f
def _best_predecessor(
self,
previous_costs: List[float],
- previous_states: List[ScoredCandidate],
+ previous_states: Tuple[ScoredCandidate, ...],
instruction: InstructionUnion,
) -> Tuple[int, float]:
best_index = 0
diff --git a/src/sampletones_core/reconstructions/reconstructor/matching.py b/src/sampletones_core/reconstructions/reconstructor/matching.py
new file mode 100644
index 000000000..d2d3d5020
--- /dev/null
+++ b/src/sampletones_core/reconstructions/reconstructor/matching.py
@@ -0,0 +1,130 @@
+from dataclasses import dataclass
+from typing import Dict, List, Tuple
+
+from sampletones_core.configs import Config
+from sampletones_core.constants.enums import GeneratorClassName
+from sampletones_core.fft import Fragment
+from sampletones_core.generators import (
+ GeneratorUnion,
+ get_generator_by_instruction,
+)
+from sampletones_core.instructions import InstructionUnion
+
+from .candidates import CandidateProvider
+from .phase import PhaseAligner
+from .scorer import Scorer
+
+
+@dataclass(frozen=True)
+class ScoredCandidate:
+ instruction: InstructionUnion
+ cost: float
+ approximation: Fragment
+
+
+Column = Tuple[ScoredCandidate, ...]
+
+
+@dataclass(frozen=True)
+class FrameMatcher:
+ """
+ Matches one target fragment against candidates of given generator classes.
+
+ Carries the matching machinery the stems assignment works from: the two-stage criterion
+ scoring and the per-candidate approximation build. What the scoring produces is a column
+ of alternatives, which the assignment turns into ownership and the decoder into a stream.
+ """
+
+ config: Config
+ candidate_provider: CandidateProvider
+ scorer: Scorer
+ phase_aligner: PhaseAligner
+
+ @property
+ def top_k(self) -> int:
+ return self.config.generation.decoder.top_k
+
+ def score_candidates(
+ self,
+ fragment: Fragment,
+ remaining_generator_classes: Dict[GeneratorClassName, GeneratorUnion],
+ ) -> List[ScoredCandidate]:
+ """
+ Score candidates in two stages: a phase-independent spectral shortlist, then a
+ full ranking with the temporal term evaluated on each phase-aligned candidate.
+
+ The shortlist ranks every candidate by the spectral term alone, which compares
+ phase-averaged features and is therefore immune to how the candidate waveform
+ happens to be phased. Each of the ``top_k`` shortlisted candidates is then built
+ at its best phase against the target and receives the full criterion cost, so
+ the temporal term measures waveform shape at the aligned phase. The aligned
+ phase stands in for the rendered phase, which keeps oscillator continuity
+ across frames.
+
+ The shortlist is drawn by spectral rank, so scoring one generator class alone
+ returns every candidate of that class that a wider scoring would have kept, and
+ with it whichever of them the wider scoring picked.
+
+ Args:
+ fragment: Target fragment to match.
+ remaining_generator_classes: Generators still available for this fragment.
+
+ Returns:
+ The shortlisted candidates with their aligned costs, best first.
+ """
+ valid_instructions, candidate_approximations = self.candidate_provider.candidates(remaining_generator_classes)
+ spectral_costs = self.scorer.spectral_costs(
+ fragment,
+ candidate_approximations,
+ )
+ shortlist = Scorer.top_k(spectral_costs, self.top_k)
+
+ scored: List[ScoredCandidate] = []
+ for index in shortlist:
+ instruction = valid_instructions[index]
+ generator = get_generator_by_instruction(
+ instruction,
+ remaining_generator_classes,
+ )
+ approximation = self.build_approximation(
+ fragment,
+ instruction,
+ generator,
+ )
+ cost = self.scorer.aligned_cost(
+ fragment,
+ float(spectral_costs[index]),
+ approximation,
+ )
+ scored.append(
+ ScoredCandidate(
+ instruction=instruction,
+ cost=cost,
+ approximation=approximation,
+ )
+ )
+
+ scored.sort(key=lambda candidate: candidate.cost)
+ return scored
+
+ def reference_energy(self, fragment: Fragment) -> float:
+ """How much sound a target holds, in the units the scoring measures its cost in."""
+ return self.scorer.reference_energy(fragment)
+
+ def build_approximation(
+ self,
+ fragment: Fragment,
+ instruction: InstructionUnion,
+ generator: GeneratorUnion,
+ ) -> Fragment:
+ """
+ Builds one candidate fragment for scoring: the phase-aligned waveform when
+ best-phase search is enabled, or the generator's library approximation.
+ """
+ if self.config.generation.calculation.find_best_phase:
+ return self.phase_aligner.align(fragment, instruction)
+
+ return self.candidate_provider.get_approximation(
+ instruction,
+ generator,
+ )
diff --git a/src/sampletones_core/reconstructions/reconstructor/reconstructor.py b/src/sampletones_core/reconstructions/reconstructor/reconstructor.py
index 7cdad4d23..444d2c142 100644
--- a/src/sampletones_core/reconstructions/reconstructor/reconstructor.py
+++ b/src/sampletones_core/reconstructions/reconstructor/reconstructor.py
@@ -1,59 +1,63 @@
+from dataclasses import dataclass
from pathlib import Path
-from typing import Dict, List, Optional
+from typing import Dict, List, Optional, Sequence, Tuple
import numpy as np
-from sampletones_core.audio import active_frame_level, load_audio
+from sampletones_core.audio import active_frame_level, common_length, load_audio, load_stems, mix
from sampletones_core.configs import Config
from sampletones_core.constants.algorithm import MINIMUM_AUDIO_LEVEL
-from sampletones_core.constants.enums import GeneratorName
+from sampletones_core.constants.enums import ChannelName
from sampletones_core.fft import FragmentedAudio, Window
from sampletones_core.generators import (
MIXER_LEVELS,
GeneratorUnion,
- get_generators_by_names,
+ get_generators_by_channels,
)
+from sampletones_core.instructions import InstructionUnion
from sampletones_core.library import InstructionLibrary, InstructionLibraryData
+from sampletones_core.reconstructions.progress import (
+ FRAMES_PREPARED,
+ PREPARATIONS,
+ RECORDINGS_LOADED,
+ STAGE_BEGUN,
+ WHOLE_STAGE,
+ ReconstructionReporter,
+ announce,
+)
+from sampletones_core.reconstructions.reconstruction.reconstruction import Reconstruction
+from sampletones_core.reconstructions.reconstruction.stems.channel_assignment import ChannelAssignment
+from sampletones_core.reconstructions.reconstruction.stems.data import StemsData
+from sampletones_core.reconstructions.reconstructor.decoder.base import Streams
+from sampletones_core.reconstructions.reconstructor.refinement import PitchRefiner
+from sampletones_core.reconstructions.reconstructor.state import ReconstructionState
+from sampletones_core.reconstructions.reconstructor.stems.assignment.frame import assign_frame
+from sampletones_core.reconstructions.reconstructor.stems.assignment.track import TrackAssignment
+from sampletones_core.reconstructions.reconstructor.stems.configs.config import StemsConfig
+from sampletones_core.reconstructions.reconstructor.worker import ReconstructorWorker
+from sampletones_core.reconstructions.stage import ReconstructionStage
from sampletones_shared.exceptions import NoLibraryDataError
from sampletones_shared.types.path import Pathlike
+from sampletones_shared.utils.progress import silent_reporter
from sampletones_shared.utils.system.paths import to_path
-from ..reconstruction.reconstruction import Reconstruction
-from .approximation import ApproximationData
-from .state import ReconstructionState
-from .worker import ReconstructorWorker
-
-
-def reconstruct(
- fragments_ids: List[int],
- fragmented_audio: FragmentedAudio,
- config: Config,
- window: Window,
- generators: Dict[GeneratorName, GeneratorUnion],
- library_data: InstructionLibraryData,
-) -> Dict[int, Dict[GeneratorName, ApproximationData]]:
- """Reconstructs the given fragments in a single worker pass.
-
- Args:
- fragments_ids: Indices of the fragments to reconstruct.
- fragmented_audio: The framed target audio.
- config: The reconstruction configuration.
- window: The analysis window.
- generators: The generators to match against, by channel name.
- library_data: The instruction library the candidates are drawn from.
-
- Returns:
- For each fragment id, the chosen approximation per generator.
+
+@dataclass(frozen=True)
+class PreparedStems:
+ """Each stem's recording at the level the matching is made on, and the frames it was cut into.
+
+ The matching reads the frames and the refinement reads the recording they came from, so both
+ travel together from the one place that scales them.
+
+ Attributes:
+ recordings: Each stem's scaled recording, keyed by stem id.
+ frames: Each stem's frames, keyed by stem id.
+ coefficient: The factor the whole set was scaled by.
"""
- worker = ReconstructorWorker(
- config=config,
- window=window,
- generators=generators,
- library_data=library_data,
- signal_length=fragmented_audio.audio.shape[0],
- )
- return worker(fragmented_audio, fragments_ids)
+ recordings: Dict[int, np.ndarray]
+ frames: Dict[int, FragmentedAudio]
+ coefficient: float
class Reconstructor:
@@ -77,7 +81,7 @@ def __init__(
"""Builds a reconstructor for a configuration and loads its library.
Args:
- config: The reconstruction configuration selecting generators, window, and
+ config: The reconstruction configuration selecting channels, window, and
matching settings.
library: The instruction library to match against; a default library rooted
at the configured directory is used when omitted.
@@ -88,8 +92,8 @@ def __init__(
self.config: Config = config
self.state: ReconstructionState = ReconstructionState.create([])
- generator_names = self.config.generation.generators
- self.generators = get_generators_by_names(config, generator_names)
+ channel_names = self.config.generation.channels
+ self.channels = get_generators_by_channels(config, channel_names)
self.window: Window = Window.from_config(self.config)
self.library_data: InstructionLibraryData = self.load_library(library)
@@ -97,8 +101,9 @@ def __init__(
def __call__(self, path: Pathlike) -> Optional[Reconstruction]:
"""Reconstructs an audio file into a :class:`Reconstruction`.
- Loads and normalizes the audio, frames it, matches every frame against the
- library, and assembles the chosen instructions into a reconstruction.
+ The classic run is the stems pipeline's single-stem case: one stem covering
+ every enabled channel on one precedence level, with the cap at the channel
+ count, so every enabled channel is assigned in every frame.
Args:
path: Path to the audio file to reconstruct.
@@ -109,17 +114,224 @@ def __call__(self, path: Pathlike) -> Optional[Reconstruction]:
Raises:
TypeError: If ``path`` is not a string or ``Path``.
"""
- if not isinstance(path, (str, Path)):
- raise TypeError("Input must be a path to an audio file")
+ stems_config = StemsConfig.single_entry(list(self.config.generation.channels))
+ return self.reconstruct([path], stems_config)
+
+ def reconstruct(
+ self,
+ paths: Sequence[Pathlike],
+ stems_config: StemsConfig,
+ *,
+ report: ReconstructionReporter = silent_reporter,
+ ) -> Optional[Reconstruction]:
+ """Reconstructs one or more stem audio files into one reconstruction.
+
+ Loads the stems onto one scale drawn from their mix, matches each stem's frames
+ against the library on its own, and assigns each frame's channels to the stems
+ following the configured hierarchy and channel cap. A stem takes a channel where
+ its own recording sounds, so what the channel carries is that recording. The
+ assignment leaves every channel in play a column of candidates per frame, which
+ the configured decoder reads into the stream that channel plays. The per-frame
+ assignment is recorded in the reconstruction's stems data.
+
+ Args:
+ paths: Paths to the stem audio files, one per stems entry.
+ stems_config: The stems setup built for this process from the inputs:
+ the entries with their channels, the precedence hierarchy, and the
+ per-stem channel cap.
+ report: Hears each stage of the run and answers whether it is still wanted.
+
+ Returns:
+ Optional[Reconstruction]: The reconstruction built from the stems.
+
+ Raises:
+ ValueError: If the entries count differently than ``paths``.
+ TypeError: If a path is not a string or ``Path``.
+ OperationCanceled: If the run is withdrawn while it is under way.
+ """
+ checked_paths = self._check_stem_paths(paths, stems_config)
+ announce(report, ReconstructionStage.LOADING, STAGE_BEGUN, PREPARATIONS)
+ recordings = self._load_stem_recordings(checked_paths)
+ announce(report, ReconstructionStage.LOADING, RECORDINGS_LOADED, PREPARATIONS)
+ prepared = self._prepare_stem_frames(recordings, stems_config)
+ announce(report, ReconstructionStage.LOADING, FRAMES_PREPARED, PREPARATIONS)
+ worker = self._build_worker(common_length(recordings))
+ assignment = self._assign_stem_frames(prepared.frames, stems_config, worker, report)
+ self._drop_resting_channels(assignment)
+ announce(report, ReconstructionStage.DECODING, STAGE_BEGUN, WHOLE_STAGE)
+ streams = worker.decoder.decode(assignment.lattices)
+ streams = self._refiner().refine(streams, assignment.stem_ids, prepared.recordings)
+ self._record_streams(streams, report)
+ return Reconstruction.from_state(
+ self.state,
+ self.config,
+ prepared.coefficient,
+ tuple(checked_paths),
+ stems_data=self._build_stems_data(stems_config, assignment.stem_ids),
+ )
+
+ def _refiner(self) -> PitchRefiner:
+ """The pass that carries each chosen note towards the fundamental the recording sounds."""
+ return PitchRefiner(config=self.config, channels=self.channels)
+
+ @staticmethod
+ def _check_stem_paths(
+ paths: Sequence[Pathlike],
+ stems_config: StemsConfig,
+ ) -> List[Path]:
+ """Validates the stem paths against the entries and converts them to ``Path``.
+
+ Raises:
+ ValueError: If the entries count differently than ``paths``.
+ TypeError: If a path is not a string or ``Path``.
+ """
+ if len(paths) != len(stems_config.entries):
+ raise ValueError(f"Expected {len(stems_config.entries)} stem paths, got {len(paths)}")
- path = to_path(path)
- audio = self.load_audio(path)
+ checked_paths: List[Path] = []
+ for path in paths:
+ if not isinstance(path, (str, Path)):
+ raise TypeError("Input must be a path to an audio file")
+
+ checked_paths.append(to_path(path))
+
+ return checked_paths
+
+ def _load_stem_recordings(self, checked_paths: List[Path]) -> Tuple[np.ndarray, ...]:
+ """Loads the recordings onto the one scale and length the run measures them on.
+
+ The set is scaled by the peak of its own mix, so each recording keeps the level it
+ holds there and the mix is the balance the recordings were captured in.
+ """
+ return load_stems(
+ checked_paths,
+ target_sample_rate=self.config.library.sample_rate,
+ normalize=self.config.general.normalize,
+ quantize=self.config.general.quantize,
+ quantization_levels=self.config.general.quantization_levels,
+ )
+
+ def _prepare_stem_frames(
+ self,
+ recordings: Sequence[np.ndarray],
+ stems_config: StemsConfig,
+ ) -> PreparedStems:
+ """Scales the recordings to the working level and frames each of them.
+
+ The level is measured on their mix, so one factor scales the whole set and a
+ recording quieter than the mix reaches its frames at the level it holds there.
+ Framing every recording on its own is what lets a stem's picks be scored against
+ the sound that stem contributes.
+
+ Args:
+ recordings: The loaded stem recordings, in entry order.
+ stems_config: The stems setup the run is made under.
+
+ Returns:
+ PreparedStems: The scaled recordings and their frames, keyed by stem id, together
+ with the coefficient they were scaled by.
+ """
+ coefficient = self.get_coefficient(mix(list(recordings)), stems_config)
self.reset_generators()
- self.state = ReconstructionState.create(list(self.generators.keys()))
- coefficient = self.get_coefficient(audio)
- fragmented_audio = self.get_fragments(audio / coefficient)
- self.reconstruct(fragmented_audio)
- return Reconstruction.from_state(self.state, self.config, coefficient, path)
+ covered = stems_config.covered_channels
+ self.state = ReconstructionState.create([name for name in ChannelName.items() if name in covered])
+ scaled = {entry.id: recording / coefficient for entry, recording in zip(stems_config.entries, recordings)}
+ return PreparedStems(
+ recordings=scaled,
+ frames={stem_id: self.get_fragments(recording) for stem_id, recording in scaled.items()},
+ coefficient=coefficient,
+ )
+
+ def _build_worker(self, signal_length: int) -> ReconstructorWorker:
+ """Builds the matching machinery and the decoder this recording runs through."""
+ return ReconstructorWorker(
+ config=self.config,
+ window=self.window,
+ channels=self.channels,
+ library_data=self.library_data,
+ signal_length=signal_length,
+ )
+
+ def _assign_stem_frames(
+ self,
+ stem_frames: Dict[int, FragmentedAudio],
+ stems_config: StemsConfig,
+ worker: ReconstructorWorker,
+ report: ReconstructionReporter,
+ ) -> TrackAssignment:
+ """Assigns every frame's channels to the stems and gathers the outcome per channel.
+
+ Every stem hands the assignment the same frame of its own recording, so a pick is
+ judged against what that stem sounds there. Each frame answers every channel in play
+ — a pick or a rest — so the lattices the decoder reads and the per-channel stem record
+ stay parallel to the frames, and stem id ``i`` names frame ``i`` of its channel.
+ """
+ assignment = TrackAssignment(self.state.channel_names)
+ frames = self._stem_frame_count(stem_frames)
+ for fragment_id in range(frames):
+ announce(report, ReconstructionStage.MATCHING, fragment_id, frames)
+ assignment.add(
+ assign_frame(
+ {stem_id: fragments[fragment_id] for stem_id, fragments in stem_frames.items()},
+ stems_config,
+ self.channels,
+ worker.matcher,
+ worker.feature_extractor,
+ worker.decoder.lattice_width,
+ )
+ )
+
+ announce(report, ReconstructionStage.MATCHING, frames, frames)
+ return assignment
+
+ @staticmethod
+ def _stem_frame_count(stem_frames: Dict[int, FragmentedAudio]) -> int:
+ """The frames every recording answers, which they share by sharing a length."""
+ return min((len(fragments) for fragments in stem_frames.values()), default=0)
+
+ def _drop_resting_channels(self, assignment: TrackAssignment) -> None:
+ """Leaves the channels that sound, releasing those that rested through every frame.
+
+ A channel no stem ever took describes nothing, so it stands by: the state releases its
+ stream and the record names it no more, which is what keeps a silent channel out of
+ every export.
+ """
+ for channel_name in assignment.resting_channels:
+ self.state.drop(channel_name)
+ assignment.drop(channel_name)
+
+ def _record_streams(self, streams: Streams, report: ReconstructionReporter) -> None:
+ """Folds the decoded streams into the state, one frame at a time.
+
+ Frame order is what carries a generator's oscillator phase from one frame into the
+ next, which is the continuity final regeneration renders against.
+ """
+ frames = self._frame_count(streams)
+ for position in range(frames):
+ announce(report, ReconstructionStage.RENDERING, position, frames)
+ for channel_name in self.state.channel_names:
+ candidate = streams[channel_name][position]
+ self._record(channel_name, candidate.instruction, candidate.approximation.audio)
+
+ announce(report, ReconstructionStage.RENDERING, frames, frames)
+
+ @staticmethod
+ def _frame_count(streams: Streams) -> int:
+ """The frames the streams span; every channel in play answers each of them."""
+ return max((len(stream) for stream in streams.values()), default=0)
+
+ @staticmethod
+ def _build_stems_data(
+ stems_config: StemsConfig,
+ assignments: Dict[ChannelName, List[int]],
+ ) -> StemsData:
+ """Assembles the per-channel per-frame stem record into serializable stems data."""
+ return StemsData(
+ config=stems_config,
+ assignments=[
+ ChannelAssignment(channel_name=channel, stem_ids=stem_ids) for channel, stem_ids in assignments.items()
+ ],
+ )
def load_audio(self, path: Path) -> np.ndarray:
"""Loads and preconditions the audio at ``path`` for reconstruction.
@@ -141,22 +353,29 @@ def load_audio(self, path: Path) -> np.ndarray:
quantization_levels=self.config.general.quantization_levels,
)
- def get_coefficient(self, audio: np.ndarray) -> float:
+ def get_coefficient(
+ self,
+ audio: np.ndarray,
+ stems_config: StemsConfig,
+ ) -> float:
"""
- Working-level coefficient that scales the input into the range the enabled
- channels span.
+ Working-level coefficient that scales the input into the range one frame spans.
The reference anchors to the robust active-frame level using the configured
percentile and audibility floor, and is floored at `MINIMUM_AUDIO_LEVEL` so
- a fully silent input yields a finite coefficient.
+ a fully silent input yields a finite coefficient. The range it is measured against
+ is what the setup's frame budget reaches: the loudest mixer weights among the covered
+ channels, as many of them as one frame can hold, so a capped run targets a level its
+ channels render.
Args:
audio: The prepared input audio.
+ stems_config: The stems setup the reconstruction runs under.
Returns:
float: The positive scale factor the input is divided by before matching.
"""
- total = sum(MIXER_LEVELS[generator.class_name()] for generator in self.generators.values())
+ total = self._frame_mixer_total(stems_config)
level = max(
active_frame_level(
audio,
@@ -168,6 +387,15 @@ def get_coefficient(self, audio: np.ndarray) -> float:
)
return float(level / total)
+ def _frame_mixer_total(self, stems_config: StemsConfig) -> float:
+ """The mixer weight one frame reaches: the loudest covered channels, up to the budget."""
+ covered = stems_config.covered_channels
+ levels = sorted(
+ (MIXER_LEVELS[generator.class_name()] for name, generator in self.channels.items() if name in covered),
+ reverse=True,
+ )
+ return sum(levels[: stems_config.frame_budget])
+
def get_fragments(self, audio: np.ndarray) -> FragmentedAudio:
"""Frames the audio into the fragments matched against the library.
@@ -179,31 +407,8 @@ def get_fragments(self, audio: np.ndarray) -> FragmentedAudio:
"""
return FragmentedAudio.create(audio, self.config, self.window)
- def reconstruct(self, fragmented_audio: FragmentedAudio) -> None:
- """Matches every fragment and records the chosen instructions in the state.
-
- Runs the matching worker over all fragments and folds each fragment's chosen
- approximation into the running reconstruction state.
-
- Args:
- fragmented_audio: The framed target audio to match.
- """
- fragments_ids = fragmented_audio.fragments_ids
- worker = ReconstructorWorker(
- config=self.config,
- window=self.window,
- generators=self.generators,
- library_data=self.library_data,
- signal_length=fragmented_audio.audio.shape[0],
- )
-
- results = worker(fragmented_audio, fragments_ids)
- for fragment_approximations in results.values():
- for fragment_approximation in fragment_approximations.values():
- self.update_state(fragment_approximation)
-
def load_library(self, library: Optional[InstructionLibrary] = None) -> InstructionLibraryData:
- """Loads and filters the instruction library for the enabled generators.
+ """Loads and filters the instruction library for the enabled channels.
Args:
library: The library to draw from; a default library rooted at the
@@ -211,7 +416,7 @@ def load_library(self, library: Optional[InstructionLibrary] = None) -> Instruct
Returns:
InstructionLibraryData: The library data restricted to the enabled
- generators' instruction types.
+ channels' instruction types.
Raises:
NoLibraryDataError: If no library exists for the configuration and window.
@@ -227,39 +432,38 @@ def load_library(self, library: Optional[InstructionLibrary] = None) -> Instruct
return InstructionLibraryData.create(
config=self.config,
data=library_data.filter(
- tuple(generator.class_name() for generator in self.generators.values()),
+ tuple(generator.class_name() for generator in self.channels.values()),
),
)
- def update_state(self, fragment_approximation: ApproximationData) -> None:
- """Appends one fragment's chosen approximation to the reconstruction state.
-
- Regenerates the approximation from its instruction when final regeneration is
- enabled, otherwise reuses the stored approximation, scaling either by the
- configured drive.
+ def _record(
+ self,
+ channel_name: ChannelName,
+ instruction: InstructionUnion,
+ matched_audio: np.ndarray,
+ ) -> None:
+ """Appends one frame of one channel to the reconstruction state.
- Args:
- fragment_approximation: The chosen approximation for one fragment and
- generator.
+ Regenerates the frame from its instruction when final regeneration is enabled, which
+ carries the oscillator's phase into the next frame, otherwise keeps the audio the match
+ was made on. Either one is scaled by the configured drive.
"""
- generator: GeneratorUnion = self.generators[fragment_approximation.generator_name]
+ generator: GeneratorUnion = self.channels[channel_name]
if self.config.generation.final_regeneration:
- instruction = fragment_approximation.instruction
- initials = generator.initials
approximation = (
generator(
instruction, # type: ignore[arg-type]
- initials=initials,
+ initials=generator.initials,
save=True,
)
* self.config.generation.drive
)
else:
- approximation = fragment_approximation.approximation.audio * self.config.generation.drive
+ approximation = matched_audio * self.config.generation.drive
- self.state.append(fragment_approximation, approximation)
+ self.state.append(channel_name, instruction, approximation)
def reset_generators(self) -> None:
- """Resets every generator so the next reconstruction starts fresh."""
- for generator in self.generators.values():
+ """Resets every channel's generator so the next reconstruction starts fresh."""
+ for generator in self.channels.values():
generator.reset()
diff --git a/src/sampletones_core/reconstructions/reconstructor/refinement/__init__.py b/src/sampletones_core/reconstructions/reconstructor/refinement/__init__.py
new file mode 100644
index 000000000..9ac177acd
--- /dev/null
+++ b/src/sampletones_core/reconstructions/reconstructor/refinement/__init__.py
@@ -0,0 +1,9 @@
+from .refiner import PitchRefiner, StemIds, StemRecordings
+from .smoothing import smoothed
+
+__all__ = [
+ "PitchRefiner",
+ "StemIds",
+ "StemRecordings",
+ "smoothed",
+]
diff --git a/src/sampletones_core/reconstructions/reconstructor/refinement/refiner.py b/src/sampletones_core/reconstructions/reconstructor/refinement/refiner.py
new file mode 100644
index 000000000..49e2f2120
--- /dev/null
+++ b/src/sampletones_core/reconstructions/reconstructor/refinement/refiner.py
@@ -0,0 +1,164 @@
+from dataclasses import dataclass, replace
+from typing import Dict, List, Optional
+
+import numpy as np
+
+from sampletones_core.configs import Config
+from sampletones_core.constants.algorithm import RESTING_STEM_ID
+from sampletones_core.constants.enums import ChannelName
+from sampletones_core.fft.instantaneous import InstantaneousPitch
+from sampletones_core.generators import (
+ GeneratorUnion,
+ PulseGenerator,
+ TonalGeneratorUnion,
+ TriangleGenerator,
+)
+from sampletones_core.instructions import PulseInstruction, TriangleInstruction
+from sampletones_core.reconstructions.reconstructor.decoder.base import Streams
+from sampletones_core.reconstructions.reconstructor.matching import ScoredCandidate
+
+from .smoothing import smoothed
+
+StemRecordings = Dict[int, np.ndarray]
+StemIds = Dict[ChannelName, List[int]]
+
+
+@dataclass(frozen=True)
+class PitchRefiner:
+ """Bends each chosen note towards where the recording's own fundamental stands.
+
+ The matching stage places every frame on the nearest note of the equal-tempered grid, which is
+ as fine as its candidate catalog goes. The hardware is finer than that everywhere below the top
+ of its range — a divider step spans well under a cent at the lowest notes — so a note the
+ matching chose can be carried closer to the sound it stands for.
+
+ Where it lands is read rather than searched. The transform already carries a phase beside every
+ magnitude, and how far that phase turns between two frames states a partial's frequency far
+ more finely than the bins are spaced; the note the decoder chose says which bins to read. So a
+ frame's bend costs a phase difference over a handful of harmonics, and the candidate catalog,
+ the per-frame scoring and the decoder's lattice all stay exactly as they were.
+
+ A frame whose sound is not pitched enough for that reading to mean anything makes no proposal,
+ and the run of bends is then settled against a toll on changing, so a stream holds a tuning
+ rather than chasing one.
+
+ Attributes:
+ config: The settings the refinement and the render are run under.
+ channels: The generator each channel sounds through, which owns the divider geometry.
+ """
+
+ config: Config
+ channels: Dict[ChannelName, GeneratorUnion]
+
+ @property
+ def active(self) -> bool:
+ """Whether this run bends its notes.
+
+ A run keeping the audio each frame was matched on would write a bend it never sounds, so
+ the refinement acts where the chosen instructions are rendered afresh.
+ """
+ return self.config.generation.refinement.enabled and self.config.generation.final_regeneration
+
+ def refine(
+ self,
+ streams: Streams,
+ stem_ids: StemIds,
+ recordings: StemRecordings,
+ ) -> Streams:
+ """The decoded streams with every frame's note bent towards what the recording sounds.
+
+ Args:
+ streams: What each channel plays, one candidate per frame.
+ stem_ids: The stem each channel took, frame by frame.
+ recordings: Each stem's recording, at the level the matching was made on.
+
+ Returns:
+ Streams: The streams, each frame carrying the bend its reading settled on.
+ """
+ if not self.active:
+ return streams
+
+ readers = {stem_id: self._reader(recording) for stem_id, recording in recordings.items()}
+ return {
+ channel_name: self._refined(channel_name, stream, stem_ids.get(channel_name, []), readers)
+ for channel_name, stream in streams.items()
+ }
+
+ def _reader(self, recording: np.ndarray) -> InstantaneousPitch:
+ """The instantaneous-pitch reading of one stem, taken once for every channel that took it."""
+ return InstantaneousPitch(
+ recording,
+ self.config.library.sample_rate,
+ self.config.library.frame_length,
+ )
+
+ def _refined(
+ self,
+ channel_name: ChannelName,
+ stream: List[ScoredCandidate],
+ stem_ids: List[int],
+ readers: Dict[int, InstantaneousPitch],
+ ) -> List[ScoredCandidate]:
+ """One channel's stream, each frame bent to the reading its neighborhood settled on."""
+ generator = self.channels[channel_name]
+ if not isinstance(generator, (PulseGenerator, TriangleGenerator)):
+ return stream
+
+ settings = self.config.generation.refinement
+ proposals = [
+ self._proposal(generator, candidate, frame, stem_ids, readers) for frame, candidate in enumerate(stream)
+ ]
+ bends = smoothed(
+ proposals,
+ window=settings.window,
+ change_weight=settings.change_weight,
+ )
+ return [self._bent(candidate, bend) for candidate, bend in zip(stream, bends)]
+
+ def _proposal(
+ self,
+ generator: TonalGeneratorUnion,
+ candidate: ScoredCandidate,
+ frame: int,
+ stem_ids: List[int],
+ readers: Dict[int, InstantaneousPitch],
+ ) -> Optional[int]:
+ """The bend one frame's reading asks for, and nothing where it has no reading to give."""
+ instruction = candidate.instruction
+ if not isinstance(instruction, (PulseInstruction, TriangleInstruction)) or not instruction.on:
+ return None
+
+ reader = self._reader_at(frame, stem_ids, readers)
+ if reader is None:
+ return None
+
+ reading = reader.at(frame, generator.sounds_at(instruction.pitch, 0))
+ if reading is None or reading.confidence < self.config.generation.refinement.confidence:
+ return None
+
+ return generator.bend_towards(instruction.pitch, reading.frequency)
+
+ @staticmethod
+ def _reader_at(
+ frame: int,
+ stem_ids: List[int],
+ readers: Dict[int, InstantaneousPitch],
+ ) -> Optional[InstantaneousPitch]:
+ """The reading of the recording this channel took at one frame, where it took one."""
+ if frame >= len(stem_ids):
+ return None
+
+ stem_id = stem_ids[frame]
+ if stem_id == RESTING_STEM_ID:
+ return None
+
+ return readers.get(stem_id)
+
+ @staticmethod
+ def _bent(candidate: ScoredCandidate, bend: int) -> ScoredCandidate:
+ """One frame carrying a bend, leaving a frame that stands at its note as it was."""
+ instruction = candidate.instruction
+ if not isinstance(instruction, (PulseInstruction, TriangleInstruction)) or bend == instruction.detune:
+ return candidate
+
+ return replace(candidate, instruction=instruction.model_copy(update={"detune": bend}))
diff --git a/src/sampletones_core/reconstructions/reconstructor/refinement/smoothing.py b/src/sampletones_core/reconstructions/reconstructor/refinement/smoothing.py
new file mode 100644
index 000000000..d447b891d
--- /dev/null
+++ b/src/sampletones_core/reconstructions/reconstructor/refinement/smoothing.py
@@ -0,0 +1,87 @@
+from typing import Dict, List, Optional, Sequence, Set
+
+NO_BEND: int = 0
+
+
+def smoothed(
+ proposals: Sequence[Optional[int]],
+ *,
+ window: int,
+ change_weight: float,
+) -> List[int]:
+ """One bend per frame, following the readings while paying for every change it makes.
+
+ A reading is made frame by frame and a frame's own reading is the best statement of where its
+ note sits, but a bend that follows every reading exactly jitters, and jitter is more audible
+ than the tuning error it chases. So the walk weighs the distance from each frame's reading
+ against a toll on changing at all, and settles on the run of bends that costs least over the
+ whole stream — the same shape the decoder settles a note contour with.
+
+ The states a frame may take are the readings its neighborhood proposed, together with no bend
+ at all. Drawing them from the readings is what keeps the walk small: the divider range a note
+ owns spans tens of steps at the bottom of the range, while the readings around any one frame
+ are a handful.
+
+ Args:
+ proposals: The bend each frame's reading asks for, ``None`` where it made none.
+ window: The frames on either side whose readings a frame may settle on.
+ change_weight: The divider steps of reading error worth avoiding one change.
+
+ Returns:
+ List[int]: The bend each frame writes.
+ """
+ if not proposals:
+ return []
+
+ states = [_states(proposals, frame, window) for frame in range(len(proposals))]
+ costs: Dict[int, float] = {bend: _reading_cost(bend, proposals[0]) for bend in states[0]}
+ origins: List[Dict[int, int]] = []
+
+ for frame in range(1, len(proposals)):
+ step: Dict[int, float] = {}
+ origin: Dict[int, int] = {}
+ for bend in states[frame]:
+ previous = _cheapest_predecessor(costs, bend, change_weight)
+ step[bend] = (
+ costs[previous] + (0.0 if previous == bend else change_weight) + _reading_cost(bend, proposals[frame])
+ )
+ origin[bend] = previous
+
+ costs = step
+ origins.append(origin)
+
+ return _walked_back(costs, origins)
+
+
+def _cheapest_predecessor(costs: Dict[int, float], bend: int, change_weight: float) -> int:
+ """The bend a frame is cheapest to arrive at ``bend`` from, holding costing nothing."""
+ reached = {held: cost + (0.0 if held == bend else change_weight) for held, cost in costs.items()}
+ return min(reached, key=lambda held: reached[held])
+
+
+def _states(proposals: Sequence[Optional[int]], frame: int, window: int) -> List[int]:
+ """The bends a frame may settle on: what its neighborhood read, and no bend at all."""
+ opening = max(0, frame - window)
+ closing = min(len(proposals), frame + window + 1)
+ nearby: Set[int] = {proposal for proposal in proposals[opening:closing] if proposal is not None}
+ nearby.add(NO_BEND)
+ return sorted(nearby)
+
+
+def _reading_cost(bend: int, proposal: Optional[int]) -> float:
+ """How far a bend stands from what the frame read, and nothing where it read nothing."""
+ if proposal is None:
+ return 0.0
+
+ return float(abs(bend - proposal))
+
+
+def _walked_back(costs: Dict[int, float], origins: List[Dict[int, int]]) -> List[int]:
+ """The least-cost run of bends, read back from the frame it ended on."""
+ bend = min(costs, key=lambda held: costs[held])
+ walked = [bend]
+ for origin in reversed(origins):
+ bend = origin[bend]
+ walked.append(bend)
+
+ return list(reversed(walked))
diff --git a/src/sampletones_core/reconstructions/reconstructor/scorer.py b/src/sampletones_core/reconstructions/reconstructor/scorer.py
index 43404fa9c..bc3abee77 100644
--- a/src/sampletones_core/reconstructions/reconstructor/scorer.py
+++ b/src/sampletones_core/reconstructions/reconstructor/scorer.py
@@ -49,6 +49,22 @@ def spectral_costs(self, target: Fragment, candidates: Fragment) -> np.ndarray:
if CUPY_AVAILABLE:
xp.get_default_memory_pool().free_all_blocks()
+ def reference_energy(self, target: Fragment) -> float:
+ """
+ How much sound a target holds, in the units its spectral loss is measured in.
+
+ A spectral cost is a fraction of this quantity, so multiplying the two states a covering
+ in absolute terms, which is what lets two targets of different loudness be compared.
+
+ Args:
+ target: Target fragment to measure.
+
+ Returns:
+ The target's weighted energy.
+ """
+ energy = self.criterion.reference_energy(xp.asarray(target.feature.values))
+ return float(to_numpy(energy).reshape(-1)[0])
+
def aligned_cost(
self,
target: Fragment,
diff --git a/src/sampletones_core/reconstructions/reconstructor/selector/__init__.py b/src/sampletones_core/reconstructions/reconstructor/selector/__init__.py
deleted file mode 100644
index e28386b83..000000000
--- a/src/sampletones_core/reconstructions/reconstructor/selector/__init__.py
+++ /dev/null
@@ -1,20 +0,0 @@
-from typing import Dict, Type
-
-from sampletones_core.constants.enums import SelectorName
-
-from .base import ScoredCandidate, Selector
-from .greedy import GreedySelector
-from .viterbi import ViterbiSelector
-
-SELECTORS: Dict[SelectorName, Type[Selector]] = {
- SelectorName.GREEDY: GreedySelector,
- SelectorName.VITERBI: ViterbiSelector,
-}
-
-__all__ = [
- "SELECTORS",
- "GreedySelector",
- "ScoredCandidate",
- "Selector",
- "ViterbiSelector",
-]
diff --git a/src/sampletones_core/reconstructions/reconstructor/selector/base.py b/src/sampletones_core/reconstructions/reconstructor/selector/base.py
deleted file mode 100644
index 751365a10..000000000
--- a/src/sampletones_core/reconstructions/reconstructor/selector/base.py
+++ /dev/null
@@ -1,129 +0,0 @@
-from abc import ABC, abstractmethod
-from dataclasses import dataclass
-from typing import Dict, List
-
-from sampletones_core.configs import Config
-from sampletones_core.constants.enums import GeneratorClassName, GeneratorName
-from sampletones_core.fft import Fragment, FragmentedAudio, Window
-from sampletones_core.fft.features import FeatureExtractor
-from sampletones_core.generators import (
- GeneratorUnion,
- get_generator_by_instruction,
- get_remaining_generator_classes,
-)
-from sampletones_core.instructions import InstructionUnion
-
-from ..approximation import ApproximationData
-from ..candidates import CandidateProvider
-from ..phase import PhaseAligner
-from ..scorer import Scorer
-
-
-@dataclass(frozen=True)
-class ScoredCandidate:
- instruction: InstructionUnion
- cost: float
- approximation: Fragment
-
-
-class Selector(ABC):
- def __init__(
- self,
- config: Config,
- window: Window,
- generators: Dict[GeneratorName, GeneratorUnion],
- scorer: Scorer,
- candidate_provider: CandidateProvider,
- phase_aligner: PhaseAligner,
- feature_extractor: FeatureExtractor,
- ) -> None:
- self.config = config
- self.window = window
- self.generators = generators
- self.scorer = scorer
- self.candidate_provider = candidate_provider
- self.phase_aligner = phase_aligner
- self.feature_extractor = feature_extractor
- self.top_k = config.generation.decoder.top_k
-
- @abstractmethod
- def select(
- self,
- fragmented_audio: FragmentedAudio,
- fragment_ids: List[int],
- ) -> Dict[int, Dict[GeneratorName, ApproximationData]]: ...
-
- def reconstruct_fragment(self, fragment: Fragment) -> Dict[GeneratorName, ApproximationData]:
- approximations: Dict[GeneratorName, ApproximationData] = {}
- remaining_generators = dict(self.generators.items())
- while remaining_generators:
- remaining_generator_classes = get_remaining_generator_classes(remaining_generators)
- approximation_data = self._find_best_approximation(fragment, remaining_generator_classes)
- fragment = self.feature_extractor.subtract(fragment, approximation_data.approximation)
- approximations[approximation_data.generator_name] = approximation_data
- del remaining_generators[approximation_data.generator_name]
-
- return approximations
-
- def _score_candidates(
- self,
- fragment: Fragment,
- remaining_generator_classes: Dict[GeneratorClassName, GeneratorUnion],
- ) -> List[ScoredCandidate]:
- """
- Score candidates in two stages: a phase-independent spectral shortlist, then a
- full ranking with the temporal term evaluated on each phase-aligned candidate.
-
- The shortlist ranks every candidate by the spectral term alone, which compares
- phase-averaged features and is therefore immune to how the candidate waveform
- happens to be phased. Each of the ``top_k`` shortlisted candidates is then built
- at its best phase against the target and receives the full criterion cost, so
- the temporal term measures waveform shape at the aligned phase. The aligned
- phase stands in for the rendered phase, which keeps oscillator continuity
- across frames.
-
- Args:
- fragment: Target fragment to match.
- remaining_generator_classes: Generators still available for this fragment.
-
- Returns:
- The shortlisted candidates with their aligned costs, best first.
- """
- valid_instructions, candidate_approximations = self.candidate_provider.candidates(remaining_generator_classes)
- spectral_costs = self.scorer.spectral_costs(fragment, candidate_approximations)
- shortlist = Scorer.top_k(spectral_costs, self.top_k)
-
- scored: List[ScoredCandidate] = []
- for index in shortlist:
- instruction = valid_instructions[index]
- generator = get_generator_by_instruction(instruction, remaining_generator_classes)
- approximation = self._build_approximation(fragment, instruction, generator)
- cost = self.scorer.aligned_cost(fragment, float(spectral_costs[index]), approximation)
- scored.append(ScoredCandidate(instruction=instruction, cost=cost, approximation=approximation))
-
- scored.sort(key=lambda candidate: candidate.cost)
- return scored
-
- def _find_best_approximation(
- self,
- fragment: Fragment,
- remaining_generator_classes: Dict[GeneratorClassName, GeneratorUnion],
- ) -> ApproximationData:
- best = self._score_candidates(fragment, remaining_generator_classes)[0]
- generator = get_generator_by_instruction(best.instruction, remaining_generator_classes)
-
- return ApproximationData(
- generator_name=GeneratorName(generator.name),
- approximation=best.approximation,
- instruction=best.instruction,
- )
-
- def _build_approximation(
- self,
- fragment: Fragment,
- instruction: InstructionUnion,
- generator: GeneratorUnion,
- ) -> Fragment:
- if self.config.generation.calculation.find_best_phase:
- return self.phase_aligner.align(fragment, instruction)
- return self.candidate_provider.get_approximation(instruction, generator)
diff --git a/src/sampletones_core/reconstructions/reconstructor/selector/greedy.py b/src/sampletones_core/reconstructions/reconstructor/selector/greedy.py
deleted file mode 100644
index 4db51f24c..000000000
--- a/src/sampletones_core/reconstructions/reconstructor/selector/greedy.py
+++ /dev/null
@@ -1,16 +0,0 @@
-from typing import Dict, List
-
-from sampletones_core.constants.enums import GeneratorName
-from sampletones_core.fft import FragmentedAudio
-
-from ..approximation import ApproximationData
-from .base import Selector
-
-
-class GreedySelector(Selector):
- def select(
- self,
- fragmented_audio: FragmentedAudio,
- fragment_ids: List[int],
- ) -> Dict[int, Dict[GeneratorName, ApproximationData]]:
- return {fragment_id: self.reconstruct_fragment(fragmented_audio[fragment_id]) for fragment_id in fragment_ids}
diff --git a/src/sampletones_core/reconstructions/reconstructor/state.py b/src/sampletones_core/reconstructions/reconstructor/state.py
index 43bdbdd3f..9d480e920 100644
--- a/src/sampletones_core/reconstructions/reconstructor/state.py
+++ b/src/sampletones_core/reconstructions/reconstructor/state.py
@@ -3,12 +3,10 @@
import numpy as np
from pydantic import BaseModel, ConfigDict
-from sampletones_core.constants.enums import GeneratorName
+from sampletones_core.constants.enums import ChannelName
from sampletones_core.fft import Fragment
from sampletones_core.instructions import InstructionUnion
-from .approximation import ApproximationData
-
class FragmentReconstructionState(BaseModel):
model_config = ConfigDict(arbitrary_types_allowed=True, frozen=True)
@@ -20,23 +18,30 @@ class FragmentReconstructionState(BaseModel):
class ReconstructionState(BaseModel):
model_config = ConfigDict(arbitrary_types_allowed=True)
- generator_names: List[GeneratorName] = []
- instructions: Dict[GeneratorName, List[InstructionUnion]] = {}
- approximations: Dict[GeneratorName, List[np.ndarray]] = {}
+ channel_names: List[ChannelName] = []
+ instructions: Dict[ChannelName, List[InstructionUnion]] = {}
+ approximations: Dict[ChannelName, List[np.ndarray]] = {}
@classmethod
- def create(cls, generator_names: List[GeneratorName]) -> Self:
+ def create(cls, channel_names: List[ChannelName]) -> Self:
return cls(
- generator_names=generator_names,
- instructions={name: [] for name in generator_names},
- approximations={name: [] for name in generator_names},
+ channel_names=channel_names,
+ instructions={name: [] for name in channel_names},
+ approximations={name: [] for name in channel_names},
)
def append(
self,
- fragment_approximation: ApproximationData,
+ channel_name: ChannelName,
+ instruction: InstructionUnion,
approximation: np.ndarray,
) -> None:
- name = fragment_approximation.generator_name
- self.instructions[name].append(fragment_approximation.instruction)
- self.approximations[name].append(approximation)
+ """Records one frame of one channel: what it plays and how it sounds."""
+ self.instructions[channel_name].append(instruction)
+ self.approximations[channel_name].append(approximation)
+
+ def drop(self, channel_name: ChannelName) -> None:
+ """Releases a channel's stream, leaving it out of the reconstruction being assembled."""
+ self.channel_names.remove(channel_name)
+ del self.instructions[channel_name]
+ del self.approximations[channel_name]
diff --git a/src/sampletones_core/reconstructions/reconstructor/stems/__init__.py b/src/sampletones_core/reconstructions/reconstructor/stems/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/src/sampletones_core/reconstructions/reconstructor/stems/assignment/__init__.py b/src/sampletones_core/reconstructions/reconstructor/stems/assignment/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/src/sampletones_core/reconstructions/reconstructor/stems/assignment/frame.py b/src/sampletones_core/reconstructions/reconstructor/stems/assignment/frame.py
new file mode 100644
index 000000000..60a481480
--- /dev/null
+++ b/src/sampletones_core/reconstructions/reconstructor/stems/assignment/frame.py
@@ -0,0 +1,63 @@
+from typing import Dict
+
+from sampletones_core.constants.enums import ChannelName
+from sampletones_core.fft import Fragment
+from sampletones_core.fft.features import FeatureExtractor
+from sampletones_core.generators import GeneratorUnion
+from sampletones_core.reconstructions.reconstructor.matching import FrameMatcher
+from sampletones_core.reconstructions.reconstructor.stems.assignment.session import AssignmentSession
+from sampletones_core.reconstructions.reconstructor.stems.assignment.validation import validate_stems_config
+from sampletones_core.reconstructions.reconstructor.stems.configs.config import StemsConfig
+from sampletones_core.reconstructions.reconstructor.stems.models.frame_assignment import StemFrameAssignment
+
+
+def assign_frame(
+ fragments: Dict[int, Fragment],
+ stems_config: StemsConfig,
+ channels: Dict[ChannelName, GeneratorUnion],
+ matcher: FrameMatcher,
+ extractor: FeatureExtractor,
+ lattice_width: int,
+) -> StemFrameAssignment:
+ """
+ Assigns one frame's channels to stems, one pick at a time.
+
+ Every pick scores a stem's candidates against what is left of that stem's own frame,
+ takes the cheapest choice across the active level, subtracts its approximation from
+ that stem's residual, and consumes its channel. Scoring each stem against its own
+ recording is what makes the channel a stem wins carry that recording's sound. Levels
+ pick in the hierarchy's mode: round-based gives every level's stems one channel per
+ round in level order, strict exhausts each level before the next. Each stem holds at
+ most the setup's channel cap per frame.
+
+ The frame is answered whole: every covered channel is either picked or reported as
+ resting, so a caller records one entry per channel per frame and the streams it
+ assembles stay parallel to the frames they describe. Every channel leaves the frame
+ with a column of alternatives ``lattice_width`` wide, which is what the decoder
+ reading the frames chooses its stream from.
+
+ Args:
+ fragments: This frame of every stem, keyed by stem id, in the matcher and
+ extractor feature space.
+ stems_config: The stems setup the assignment runs under.
+ channels: The enabled channels with their generators.
+ matcher: The candidate scoring machinery.
+ extractor: The feature extractor whose subtraction forms a residual.
+ lattice_width: How many alternatives per channel the decoder reads.
+
+ Returns:
+ The picks in the order they were made, together with the channels left resting.
+
+ Raises:
+ ValueError: If a stem allows a channel the enabled channels lack.
+ """
+ validate_stems_config(stems_config, channels)
+ session = AssignmentSession(
+ fragments,
+ stems_config,
+ channels,
+ matcher,
+ extractor,
+ lattice_width,
+ )
+ return session.run()
diff --git a/src/sampletones_core/reconstructions/reconstructor/stems/assignment/session.py b/src/sampletones_core/reconstructions/reconstructor/stems/assignment/session.py
new file mode 100644
index 000000000..83968bf72
--- /dev/null
+++ b/src/sampletones_core/reconstructions/reconstructor/stems/assignment/session.py
@@ -0,0 +1,275 @@
+from dataclasses import dataclass
+from typing import Dict, FrozenSet, List, Optional, Sequence, Set, Tuple
+
+import numpy as np
+
+from sampletones_core.constants.algorithm import (
+ RESTING_FRAME_COST,
+ SINGLE_STATE_LATTICE_WIDTH,
+ STEM_ACTIVITY_FLOOR,
+)
+from sampletones_core.constants.enums import (
+ ChannelName,
+ GeneratorClassName,
+ HierarchyMode,
+)
+from sampletones_core.fft import Fragment
+from sampletones_core.fft.features import FeatureExtractor
+from sampletones_core.generators import (
+ GeneratorUnion,
+ get_generator_by_instruction,
+ get_remaining_generator_classes,
+)
+from sampletones_core.reconstructions.reconstructor.matching import (
+ Column,
+ FrameMatcher,
+ ScoredCandidate,
+)
+from sampletones_core.reconstructions.reconstructor.stems.configs.config import (
+ StemsConfig,
+)
+from sampletones_core.reconstructions.reconstructor.stems.models.choice import (
+ StemChoice,
+)
+from sampletones_core.reconstructions.reconstructor.stems.models.frame_assignment import (
+ StemFrameAssignment,
+)
+from sampletones_core.reconstructions.reconstructor.stems.models.rest import StemRest
+from sampletones_shared.array import to_numpy
+
+
+@dataclass(frozen=True)
+class StemOffer:
+ """What one stem offers for its residual: a shortlist, and the channel it won with.
+
+ The shortlist arrives best first, so its head is the candidate the stem competes with, and
+ the generator behind that head names the channel the stem would take.
+ """
+
+ stem_id: int
+ generator: GeneratorUnion
+ shortlist: Tuple[ScoredCandidate, ...]
+ generator_classes: Dict[GeneratorClassName, GeneratorUnion]
+ residual_energy: float
+
+ @property
+ def candidate(self) -> ScoredCandidate:
+ return self.shortlist[0]
+
+ @property
+ def bid(self) -> float:
+ """How much of what this stem still holds its best candidate covers.
+
+ A cost is a fraction of its own target's energy, so two stems' costs stand on different
+ scales and comparing them alone would hand a channel to whichever recording is easiest to
+ approximate. Weighting the cost by the energy behind it states the covering in absolute
+ terms, so the channel goes to the stem with the most sound left to render.
+ """
+ return self.residual_energy * max(0.0, 1.0 - self.candidate.cost)
+
+ @property
+ def class_restricted(self) -> bool:
+ """The shortlist covers this offer's generator class alone, so it is the channel's own column."""
+ return len(self.generator_classes) == 1
+
+
+class AssignmentSession:
+ """
+ Carries one frame assignment's mutable progress: each stem's residual, the free
+ channels, and the per-stem channel counts.
+
+ A stem's residual holds what is left of that stem's own frame, so a pick answers what
+ that recording still sounds and the channel it wins carries that recording. Only the
+ free channels are shared: the stems compete for them, ordered by the hierarchy, and the
+ stems sounding in the frame are the ones that compete.
+ """
+
+ def __init__(
+ self,
+ fragments: Dict[int, Fragment],
+ stems_config: StemsConfig,
+ channels: Dict[ChannelName, GeneratorUnion],
+ matcher: FrameMatcher,
+ extractor: FeatureExtractor,
+ lattice_width: int,
+ ) -> None:
+ self.fragments = fragments
+ self.stems_config = stems_config
+ self.channels = channels
+ self.matcher = matcher
+ self.extractor = extractor
+ self.lattice_width = lattice_width
+ self.channel_cap = stems_config.channel_cap
+ self.residuals: Dict[int, Fragment] = dict(fragments)
+ covered = stems_config.covered_channels
+ self.free_channels = [name for name in ChannelName.items() if name in covered]
+ self.used_channels: Dict[int, int] = {entry.id: 0 for entry in stems_config.entries}
+ self.sounding = self._sounding_stems()
+ self.choices: List[StemChoice] = []
+
+ def run(self) -> StemFrameAssignment:
+ """Runs the frame's picks and reports them together with the channels left resting."""
+ match self.stems_config.hierarchy.mode:
+ case HierarchyMode.ROUND_ROBIN:
+ self._round_robin()
+ case HierarchyMode.STRICT:
+ self._strict()
+
+ return StemFrameAssignment(
+ choices=tuple(self.choices),
+ rests=self._rests(),
+ )
+
+ def _round_robin(self) -> None:
+ for _ in range(self.channel_cap):
+ for level in self.stems_config.hierarchy.levels:
+ if not self.free_channels:
+ return
+
+ self._pick_from_level(level, repeat=False)
+
+ def _strict(self) -> None:
+ for level in self.stems_config.hierarchy.levels:
+ if not self.free_channels:
+ return
+
+ self._pick_from_level(level, repeat=True)
+
+ def _pick_from_level(
+ self,
+ level: Sequence[int],
+ *,
+ repeat: bool,
+ ) -> None:
+ picked_this_visit: Set[int] = set()
+ while True:
+ eligible = [
+ stem_id
+ for stem_id in level
+ if stem_id in self.sounding
+ and self.used_channels[stem_id] < self.channel_cap
+ and (repeat or stem_id not in picked_this_visit)
+ ]
+ if not eligible or not self.free_channels:
+ return
+
+ offer = self._best_offer(eligible)
+ if offer is None:
+ return
+
+ choice = self._choice(offer)
+ self.choices.append(choice)
+ self.used_channels[choice.stem_id] += 1
+ self.free_channels.remove(choice.channel_name)
+ self.residuals[choice.stem_id] = self.extractor.subtract(
+ self.residuals[choice.stem_id],
+ choice.approximation,
+ )
+ picked_this_visit.add(choice.stem_id)
+
+ def _best_offer(self, stem_ids: Sequence[int]) -> Optional[StemOffer]:
+ """The stem of ``stem_ids`` whose best candidate covers the most of what it still holds.
+
+ Equal bids leave the offer already standing, which is the one earlier in level order, so a
+ rerun of the same frame assigns the same way.
+ """
+ best: Optional[StemOffer] = None
+ for stem_id in stem_ids:
+ remaining_channels = self._remaining_channels(stem_id)
+ if not remaining_channels:
+ continue
+
+ remaining_generator_classes = get_remaining_generator_classes(remaining_channels)
+ scored = self.matcher.score_candidates(
+ self.residuals[stem_id],
+ remaining_generator_classes,
+ )
+ offer = StemOffer(
+ stem_id=stem_id,
+ generator=get_generator_by_instruction(
+ scored[0].instruction,
+ remaining_generator_classes,
+ ),
+ shortlist=tuple(scored),
+ generator_classes=remaining_generator_classes,
+ residual_energy=self.matcher.reference_energy(self.residuals[stem_id]),
+ )
+ if best is None or offer.bid > best.bid:
+ best = offer
+
+ return best
+
+ def _choice(self, offer: StemOffer) -> StemChoice:
+ return StemChoice(
+ stem_id=offer.stem_id,
+ channel_name=ChannelName(offer.generator.name),
+ instruction=offer.candidate.instruction,
+ approximation=offer.candidate.approximation,
+ cost=offer.candidate.cost,
+ column=self._column(offer),
+ )
+
+ def _column(self, offer: StemOffer) -> Column:
+ """The alternatives the decoder chooses among for the channel this offer won.
+
+ A decoder reading one candidate per frame settles on the pick itself, so the frame is
+ answered by the scoring already done. A wider lattice scores the winning channel's own
+ candidates against the residual the pick was made on, which reaches the alternatives a
+ scoring across several channels ranked below other channels' candidates. Where the offer
+ was already scored over one generator class, that scoring is the column.
+ """
+ if self.lattice_width == SINGLE_STATE_LATTICE_WIDTH:
+ return (offer.candidate,)
+
+ if offer.class_restricted:
+ return offer.shortlist[: self.lattice_width]
+
+ generator = offer.generator
+ scored = self.matcher.score_candidates(
+ self.residuals[offer.stem_id],
+ {generator.class_name(): generator},
+ )
+ return tuple(scored[: self.lattice_width])
+
+ def _rests(self) -> Tuple[StemRest, ...]:
+ """The channels no stem took, each holding its null instruction over a silent frame."""
+ if not self.free_channels:
+ return ()
+
+ silent = next(iter(self.fragments.values())) * 0.0
+ return tuple(
+ StemRest(
+ channel_name=channel_name,
+ column=(self._resting_candidate(channel_name, silent),),
+ )
+ for channel_name in self.free_channels
+ )
+
+ def _resting_candidate(self, channel_name: ChannelName, silent: Fragment) -> ScoredCandidate:
+ instruction = self.channels[channel_name].get_instruction_type().null_instruction()
+ return ScoredCandidate(
+ instruction=instruction,
+ cost=RESTING_FRAME_COST,
+ approximation=silent,
+ )
+
+ def _sounding_stems(self) -> FrozenSet[int]:
+ """The stems whose own frame reaches a level a channel can render.
+
+ A frame quieter than the quietest note the hardware plays, measured against the working
+ level, holds nothing for a channel to sound. Its stem stands aside, so the channel goes
+ to a stem that does sound there or rests, and a recording silent through a passage keeps
+ that passage silent on the channels it holds.
+ """
+ return frozenset(
+ stem_id
+ for stem_id, fragment in self.fragments.items()
+ if float(np.max(np.abs(to_numpy(fragment.audio)), initial=0.0)) >= STEM_ACTIVITY_FLOOR
+ )
+
+ def _remaining_channels(
+ self,
+ stem_id: int,
+ ) -> Dict[ChannelName, GeneratorUnion]:
+ allowed = self.stems_config.entries_by_id[stem_id].channel_set
+ return {name: self.channels[name] for name in self.free_channels if name in allowed}
diff --git a/src/sampletones_core/reconstructions/reconstructor/stems/assignment/track.py b/src/sampletones_core/reconstructions/reconstructor/stems/assignment/track.py
new file mode 100644
index 000000000..45c460bed
--- /dev/null
+++ b/src/sampletones_core/reconstructions/reconstructor/stems/assignment/track.py
@@ -0,0 +1,48 @@
+from typing import Dict, List, Sequence
+
+from sampletones_core.constants.algorithm import RESTING_STEM_ID
+from sampletones_core.constants.enums import ChannelName
+from sampletones_core.reconstructions.reconstructor.decoder.base import ChannelLattice, Lattices
+from sampletones_core.reconstructions.reconstructor.matching import Column
+from sampletones_core.reconstructions.reconstructor.stems.models.frame_assignment import StemFrameAssignment
+
+
+class TrackAssignment:
+ """
+ Gathers a recording's frame assignments into what the rest of the reconstruction reads.
+
+ Two records grow side by side, both in frame order: the lattice each channel offers the
+ decoder, and the stem that owns each of the channel's frames. Keeping them parallel is
+ what lets a stem selection name the frames it sounds in.
+ """
+
+ def __init__(self, channel_names: Sequence[ChannelName]) -> None:
+ self.lattices: Lattices = {channel_name: [] for channel_name in channel_names}
+ self.stem_ids: Dict[ChannelName, List[int]] = {channel_name: [] for channel_name in channel_names}
+
+ def add(self, frame_assignment: StemFrameAssignment) -> None:
+ """Appends one frame: every channel in play gains its column and the stem that took it."""
+ for choice in frame_assignment.choices:
+ self._append(choice.channel_name, choice.stem_id, choice.column)
+
+ for rest in frame_assignment.rests:
+ self._append(rest.channel_name, RESTING_STEM_ID, rest.column)
+
+ def drop(self, channel_name: ChannelName) -> None:
+ """Releases a channel's records, leaving it out of the reconstruction being assembled."""
+ del self.lattices[channel_name]
+ del self.stem_ids[channel_name]
+
+ @property
+ def resting_channels(self) -> List[ChannelName]:
+ """The channels that rested through every frame, which sound nothing anywhere."""
+ return [
+ channel_name
+ for channel_name, stem_ids in self.stem_ids.items()
+ if all(stem_id == RESTING_STEM_ID for stem_id in stem_ids)
+ ]
+
+ def _append(self, channel_name: ChannelName, stem_id: int, column: Column) -> None:
+ lattice: ChannelLattice = self.lattices[channel_name]
+ lattice.append(column)
+ self.stem_ids[channel_name].append(stem_id)
diff --git a/src/sampletones_core/reconstructions/reconstructor/stems/assignment/validation.py b/src/sampletones_core/reconstructions/reconstructor/stems/assignment/validation.py
new file mode 100644
index 000000000..597f307d4
--- /dev/null
+++ b/src/sampletones_core/reconstructions/reconstructor/stems/assignment/validation.py
@@ -0,0 +1,25 @@
+from typing import Dict
+
+from sampletones_core.constants.enums import ChannelName
+from sampletones_core.generators import GeneratorUnion
+from sampletones_core.reconstructions.reconstructor.stems.configs.config import StemsConfig
+
+
+def validate_stems_config(
+ stems_config: StemsConfig,
+ channels: Dict[ChannelName, GeneratorUnion],
+) -> None:
+ """Holds a stems setup against the channels the reconstruction enables.
+
+ The setup states its own consistency — unique ids, a hierarchy naming every entry, a cap of
+ at least one — so what is left to check is the pairing with this run: every channel a stem
+ may occupy has a generator to render it.
+
+ Raises:
+ ValueError: If a stem allows a channel the configuration lacks.
+ """
+ enabled = set(channels)
+ for entry in stems_config.entries:
+ foreign = entry.channel_set - enabled
+ if foreign:
+ raise ValueError(f"Stem {entry.id} allows channels the configuration lacks: {sorted(foreign)}")
diff --git a/src/sampletones_core/reconstructions/reconstructor/stems/configs/__init__.py b/src/sampletones_core/reconstructions/reconstructor/stems/configs/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/src/sampletones_core/reconstructions/reconstructor/stems/configs/config.py b/src/sampletones_core/reconstructions/reconstructor/stems/configs/config.py
new file mode 100644
index 000000000..fcaa1d30d
--- /dev/null
+++ b/src/sampletones_core/reconstructions/reconstructor/stems/configs/config.py
@@ -0,0 +1,87 @@
+from functools import cached_property
+from typing import Dict, FrozenSet, List, Self
+
+from pydantic import ConfigDict, Field, model_validator
+
+from sampletones_core.constants.algorithm import (
+ ALL_STEMS_CHANNEL_CAP,
+ DEFAULT_STEMS_CHANNEL_CAP,
+)
+from sampletones_core.constants.enums import ChannelName
+from sampletones_core.data import DataModel
+from sampletones_core.reconstructions.reconstructor.stems.configs.entry import StemEntry
+from sampletones_core.reconstructions.reconstructor.stems.configs.hierarchy import StemsHierarchy
+
+
+class StemsConfig(DataModel):
+ model_config = ConfigDict(extra="forbid", frozen=True)
+
+ entries: List[StemEntry] = Field(
+ default_factory=list,
+ description="The competing stems and the channels each may occupy",
+ )
+ hierarchy: StemsHierarchy = Field(
+ default_factory=StemsHierarchy,
+ description="The precedence structure of the stems assignment",
+ )
+ channel_cap: int = Field(
+ default=DEFAULT_STEMS_CHANNEL_CAP,
+ ge=1,
+ description="The most channels one stem holds per frame",
+ )
+
+ @classmethod
+ def single_entry(
+ cls,
+ channels: List[ChannelName],
+ *,
+ channel_cap: int = ALL_STEMS_CHANNEL_CAP,
+ ) -> Self:
+ """The setup for one stem covering ``channels``, the classic run's shape.
+
+ One entry holding every channel on a single precedence level reproduces the classic
+ greedy pick when the cap equals the channel count, so this setup describes both a
+ single-file conversion and the stems pipeline's simplest case.
+ """
+ return cls(
+ entries=[StemEntry(id=0, channels=channels)],
+ hierarchy=StemsHierarchy(levels=[[0]]),
+ channel_cap=channel_cap,
+ )
+
+ @cached_property
+ def entries_by_id(self) -> Dict[int, StemEntry]:
+ """The entries keyed by the id the hierarchy names them with."""
+ return {entry.id: entry for entry in self.entries}
+
+ @cached_property
+ def covered_channels(self) -> FrozenSet[ChannelName]:
+ """Every channel some stem may occupy, which is the set an assignment puts in play."""
+ return frozenset(channel for entry in self.entries for channel in entry.channels)
+
+ @property
+ def frame_budget(self) -> int:
+ """The most channels that can sound in one frame under this setup.
+
+ Each stem holds at most ``channel_cap`` channels per frame and every held channel is one
+ of the covered ones, so the smaller of the two bounds is what a frame can reach. The
+ working level is measured against this budget, which keeps a capped run's target within
+ what its channels render.
+ """
+ return min(len(self.covered_channels), len(self.entries) * self.channel_cap)
+
+ @model_validator(mode="after")
+ def _validate_unique_entry_ids(self) -> Self:
+ ids = [entry.id for entry in self.entries]
+ if len(set(ids)) != len(ids):
+ raise ValueError("Stem entries must have unique ids")
+
+ return self
+
+ @model_validator(mode="after")
+ def _validate_hierarchy_names_every_entry(self) -> Self:
+ referenced = sorted(stem_id for level in self.hierarchy.levels for stem_id in level)
+ if referenced != sorted(entry.id for entry in self.entries):
+ raise ValueError("Hierarchy levels must name every stem exactly once")
+
+ return self
diff --git a/src/sampletones_core/reconstructions/reconstructor/stems/configs/entry.py b/src/sampletones_core/reconstructions/reconstructor/stems/configs/entry.py
new file mode 100644
index 000000000..822ab5b65
--- /dev/null
+++ b/src/sampletones_core/reconstructions/reconstructor/stems/configs/entry.py
@@ -0,0 +1,25 @@
+from functools import cached_property
+from typing import FrozenSet, List
+
+from pydantic import ConfigDict, Field
+
+from sampletones_core.constants.enums import ChannelName
+from sampletones_core.data import DataModel
+
+
+class StemEntry(DataModel):
+ model_config = ConfigDict(extra="forbid", frozen=True)
+
+ id: int = Field(
+ ...,
+ description="Identifier of the stem the hierarchy references",
+ )
+ channels: List[ChannelName] = Field(
+ ...,
+ description="The channels the stem may occupy",
+ )
+
+ @cached_property
+ def channel_set(self) -> FrozenSet[ChannelName]:
+ """The channels this stem may occupy, in the form an assignment tests membership against."""
+ return frozenset(self.channels)
diff --git a/src/sampletones_core/reconstructions/reconstructor/stems/configs/hierarchy.py b/src/sampletones_core/reconstructions/reconstructor/stems/configs/hierarchy.py
new file mode 100644
index 000000000..842e8d8ac
--- /dev/null
+++ b/src/sampletones_core/reconstructions/reconstructor/stems/configs/hierarchy.py
@@ -0,0 +1,22 @@
+from typing import List
+
+from pydantic import ConfigDict, Field
+
+from sampletones_core.constants.algorithm import (
+ DEFAULT_STEMS_HIERARCHY_MODE,
+)
+from sampletones_core.constants.enums import HierarchyMode
+from sampletones_core.data import DataModel
+
+
+class StemsHierarchy(DataModel):
+ model_config = ConfigDict(extra="forbid", frozen=True)
+
+ levels: List[List[int]] = Field(
+ default_factory=list,
+ description="Stem id levels, picked in the order listed",
+ )
+ mode: HierarchyMode = Field(
+ default=DEFAULT_STEMS_HIERARCHY_MODE,
+ description="Whether levels alternate per round or exhaust in order",
+ )
diff --git a/src/sampletones_core/reconstructions/reconstructor/stems/models/__init__.py b/src/sampletones_core/reconstructions/reconstructor/stems/models/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/src/sampletones_core/reconstructions/reconstructor/stems/models/choice.py b/src/sampletones_core/reconstructions/reconstructor/stems/models/choice.py
new file mode 100644
index 000000000..ab53af679
--- /dev/null
+++ b/src/sampletones_core/reconstructions/reconstructor/stems/models/choice.py
@@ -0,0 +1,22 @@
+from typing import NamedTuple
+
+from sampletones_core.constants.enums import ChannelName
+from sampletones_core.fft import Fragment
+from sampletones_core.instructions import InstructionUnion
+from sampletones_core.reconstructions.reconstructor.matching import Column
+
+
+class StemChoice(NamedTuple):
+ """One stem's claim on one channel for one frame.
+
+ The instruction, approximation and cost are what won the channel and what the residual
+ the next pick sees was formed from. The column holds the alternatives the decoder chooses
+ among for the same channel and frame, the winner among them.
+ """
+
+ stem_id: int
+ channel_name: ChannelName
+ instruction: InstructionUnion
+ approximation: Fragment
+ cost: float
+ column: Column
diff --git a/src/sampletones_core/reconstructions/reconstructor/stems/models/frame_assignment.py b/src/sampletones_core/reconstructions/reconstructor/stems/models/frame_assignment.py
new file mode 100644
index 000000000..64993a80c
--- /dev/null
+++ b/src/sampletones_core/reconstructions/reconstructor/stems/models/frame_assignment.py
@@ -0,0 +1,24 @@
+from typing import Dict, NamedTuple, Tuple
+
+from sampletones_core.constants.enums import ChannelName
+from sampletones_core.reconstructions.reconstructor.stems.models.choice import StemChoice
+from sampletones_core.reconstructions.reconstructor.stems.models.rest import StemRest
+
+
+class StemFrameAssignment(NamedTuple):
+ """One frame's outcome: the picks that were made and the channels left resting.
+
+ Together the two cover every channel the setup puts in play, which is what lets a
+ reconstruction record one entry per channel for each frame.
+ """
+
+ choices: Tuple[StemChoice, ...]
+ rests: Tuple[StemRest, ...]
+
+ @property
+ def by_channel(self) -> Dict[ChannelName, StemChoice]:
+ return {choice.channel_name: choice for choice in self.choices}
+
+ @property
+ def resting(self) -> Tuple[ChannelName, ...]:
+ return tuple(rest.channel_name for rest in self.rests)
diff --git a/src/sampletones_core/reconstructions/reconstructor/stems/models/rest.py b/src/sampletones_core/reconstructions/reconstructor/stems/models/rest.py
new file mode 100644
index 000000000..c42559492
--- /dev/null
+++ b/src/sampletones_core/reconstructions/reconstructor/stems/models/rest.py
@@ -0,0 +1,15 @@
+from typing import NamedTuple
+
+from sampletones_core.constants.enums import ChannelName
+from sampletones_core.reconstructions.reconstructor.matching import Column
+
+
+class StemRest(NamedTuple):
+ """One channel's frame that every stem left free: the null instruction it holds, alone.
+
+ A rest reaches the decoder as a column like any other, one state wide, so the channel keeps
+ its place in the frame and the silence it sounds is the frame's own answer.
+ """
+
+ channel_name: ChannelName
+ column: Column
diff --git a/src/sampletones_core/reconstructions/reconstructor/worker.py b/src/sampletones_core/reconstructions/reconstructor/worker.py
index f5ffe44e4..39fcb6425 100644
--- a/src/sampletones_core/reconstructions/reconstructor/worker.py
+++ b/src/sampletones_core/reconstructions/reconstructor/worker.py
@@ -1,28 +1,34 @@
from dataclasses import dataclass, field
-from typing import Dict, List
+from typing import Dict
from sampletones_core.configs import Config
-from sampletones_core.constants.enums import GeneratorClassName, GeneratorName
-from sampletones_core.fft import Fragment, FragmentedAudio, Window
+from sampletones_core.constants.enums import ChannelName
+from sampletones_core.fft import Window
from sampletones_core.fft.features import FeatureExtractor, get_feature_extractor
-from sampletones_core.generators import (
- GeneratorUnion,
- get_remaining_generator_classes,
-)
+from sampletones_core.generators import GeneratorUnion
from sampletones_core.library import InstructionLibraryData
-from .approximation import ApproximationData
from .candidates import CandidateProvider
+from .decoder import DECODERS, Decoder
+from .matching import FrameMatcher
from .phase import PHASE_ALIGNERS, PhaseAligner
from .scorer import Scorer
-from .selector import SELECTORS, Selector
@dataclass(frozen=True)
class ReconstructorWorker:
+ """
+ Assembles the machinery one recording is matched and decoded with.
+
+ Everything a reconstruction run needs beyond its target sits here, built once for the
+ signal it will work on: the scorer and candidate provider the matching draws from, the
+ phase aligner and feature extractor it measures with, the `FrameMatcher` the stems
+ assignment scores through, and the `Decoder` the configuration names.
+ """
+
config: Config
window: Window
- generators: Dict[GeneratorName, GeneratorUnion]
+ channels: Dict[ChannelName, GeneratorUnion]
library_data: InstructionLibraryData
signal_length: int
@@ -30,7 +36,8 @@ class ReconstructorWorker:
candidate_provider: CandidateProvider = field(init=False)
phase_aligner: PhaseAligner = field(init=False)
feature_extractor: FeatureExtractor = field(init=False)
- selector: Selector = field(init=False)
+ matcher: FrameMatcher = field(init=False)
+ decoder: Decoder = field(init=False)
def __post_init__(self) -> None:
scorer = Scorer(self.config, self.window, self.signal_length)
@@ -38,35 +45,17 @@ def __post_init__(self) -> None:
phase_aligner_class = PHASE_ALIGNERS[self.config.generation.calculation.phase_aligner]
phase_aligner = phase_aligner_class(self.config, self.window, self.library_data)
feature_extractor = get_feature_extractor(self.config, self.window)
- selector_class = SELECTORS[self.config.generation.decoder.selector]
- selector = selector_class(
+ matcher = FrameMatcher(
config=self.config,
- window=self.window,
- generators=self.generators,
- scorer=scorer,
candidate_provider=candidate_provider,
+ scorer=scorer,
phase_aligner=phase_aligner,
- feature_extractor=feature_extractor,
)
+ decoder_class = DECODERS[self.config.generation.decoder.selector]
object.__setattr__(self, "scorer", scorer)
object.__setattr__(self, "candidate_provider", candidate_provider)
object.__setattr__(self, "phase_aligner", phase_aligner)
object.__setattr__(self, "feature_extractor", feature_extractor)
- object.__setattr__(self, "selector", selector)
-
- def __call__(
- self,
- fragmented_audio: FragmentedAudio,
- fragment_ids: List[int],
- ) -> Dict[int, Dict[GeneratorName, ApproximationData]]:
- return self.selector.select(fragmented_audio, fragment_ids)
-
- def reconstruct(self, fragment: Fragment) -> Dict[GeneratorName, ApproximationData]:
- return self.selector.reconstruct_fragment(fragment)
-
- def get_remaining_generator_classes(
- self,
- remaining_generators: Dict[GeneratorName, GeneratorUnion],
- ) -> Dict[GeneratorClassName, GeneratorUnion]:
- return get_remaining_generator_classes(remaining_generators)
+ object.__setattr__(self, "matcher", matcher)
+ object.__setattr__(self, "decoder", decoder_class(self.config))
diff --git a/src/sampletones_core/reconstructions/stage.py b/src/sampletones_core/reconstructions/stage.py
new file mode 100644
index 000000000..686ef53a9
--- /dev/null
+++ b/src/sampletones_core/reconstructions/stage.py
@@ -0,0 +1,65 @@
+from enum import StrEnum
+from typing import Final, Mapping
+
+
+class ReconstructionStage(StrEnum):
+ """The work a reconstruction is in the middle of, as the progress it reports names it.
+
+ A run reads its recordings onto one scale, matches every frame against the library, reads each
+ channel's frames into the stream it plays, and renders that stream back to the audio the
+ reconstruction carries. Each stage counts in its own unit, so what a report means is read from
+ the stage it names.
+
+ Matching visits the library once per frame per stem and is what a run spends its time on, which
+ is what :data:`STAGE_WEIGHTS` states: the bulk of a reading belongs to matching so a bar tracks
+ the time a run actually takes, while the stages around it keep enough of it to move visibly as
+ they pass. The weights are approximations measured over whole runs, and matching earns a larger
+ share the longer the recording is, so the stages around it are given what they hold on a short
+ one — where a bar standing still is noticed.
+
+ The weights are counts rather than fractions, so what a stage is worth is stated against the
+ others and a reading is one division at the point of use — which is what lets the last stage
+ arrive exactly at the whole run.
+ """
+
+ LOADING = "loading"
+ MATCHING = "matching"
+ DECODING = "decoding"
+ RENDERING = "rendering"
+
+ @property
+ def weight(self) -> int:
+ """What this stage costs, against the other stages of a run."""
+ return STAGE_WEIGHTS[self]
+
+ @property
+ def preceding_weight(self) -> int:
+ """What the stages before this one cost together."""
+ preceding = 0
+ for stage in ReconstructionStage:
+ if stage is self:
+ break
+
+ preceding += stage.weight
+
+ return preceding
+
+ @property
+ def share(self) -> float:
+ """How much of a whole reconstruction this stage stands for."""
+ return self.weight / TOTAL_STAGE_WEIGHT
+
+ @property
+ def offset(self) -> float:
+ """How much of a reconstruction stands finished when this stage begins."""
+ return self.preceding_weight / TOTAL_STAGE_WEIGHT
+
+
+STAGE_WEIGHTS: Final[Mapping[ReconstructionStage, int]] = {
+ ReconstructionStage.LOADING: 8,
+ ReconstructionStage.MATCHING: 82,
+ ReconstructionStage.DECODING: 2,
+ ReconstructionStage.RENDERING: 8,
+}
+
+TOTAL_STAGE_WEIGHT: Final[int] = sum(STAGE_WEIGHTS.values())
diff --git a/src/sampletones_core/scripts/library.py b/src/sampletones_core/scripts/library.py
index 9c2c44859..068e44a4d 100644
--- a/src/sampletones_core/scripts/library.py
+++ b/src/sampletones_core/scripts/library.py
@@ -50,13 +50,13 @@ def on_progress(
if task_status in (
TaskStatus.COMPLETED,
- TaskStatus.CANCELLED,
+ TaskStatus.CANCELED,
TaskStatus.FAILED,
):
progress_bar.close()
- def on_cancelled() -> None:
- logger.info("Library generation cancelled by user")
+ def on_canceled() -> None:
+ logger.info("Library generation canceled by user")
progress_bar.close()
def on_error(_exception: Exception) -> None:
@@ -66,7 +66,7 @@ def on_error(_exception: Exception) -> None:
on_start=on_start,
on_completed=on_completed,
on_progress=on_progress,
- on_cancelled=on_cancelled,
+ on_canceled=on_canceled,
on_error=on_error,
)
diff --git a/src/sampletones_core/scripts/reconstruction.py b/src/sampletones_core/scripts/reconstruction.py
index 8037aa256..6cdf7c7a6 100644
--- a/src/sampletones_core/scripts/reconstruction.py
+++ b/src/sampletones_core/scripts/reconstruction.py
@@ -1,5 +1,5 @@
from pathlib import Path
-from typing import Optional
+from typing import Final, Optional, Tuple
from tqdm import tqdm
@@ -8,13 +8,19 @@
from sampletones_core.parallelization import TaskProgress, TaskStatus
from sampletones_core.reconstructions import Reconstructor
from sampletones_core.reconstructions.converter import (
+ ConversionJob,
+ DirectoryConversion,
ReconstructionConverter,
get_output_path,
+ reconstruct_job,
)
-from sampletones_core.reconstructions.converter import reconstruct_file as _reconstruct_file
+from sampletones_core.reconstructions.progress import ReconstructionProgress
+from sampletones_core.reconstructions.reconstructor.stems.configs.config import StemsConfig
from sampletones_core.scripts.library import generate_library
from sampletones_shared.logger import logger, null_logger
+BAR_STEPS: Final[int] = 1000
+
def reconstruct_file(
input_path: Path,
@@ -32,8 +38,23 @@ def reconstruct_file(
raise IsADirectoryError(f"Expected a file path, got directory path: {input_path}")
logger.info(f"Starting reconstruction for file {input_path}")
- reconstructor = Reconstructor(config)
- _reconstruct_file((reconstructor, input_path, output_path))
+ job = ConversionJob(
+ sources=(input_path,),
+ stems=_classic_setup(config),
+ output_path=output_path,
+ )
+ progress_bar = tqdm(total=BAR_STEPS, desc=f"Reconstructing {input_path.name}", unit="step")
+
+ def on_progress(progress: ReconstructionProgress) -> bool:
+ progress_bar.set_postfix_str(progress.stage)
+ progress_bar.update(round(progress.fraction * BAR_STEPS) - progress_bar.n)
+ return True
+
+ try:
+ reconstruct_job((Reconstructor(config), job, on_progress))
+ finally:
+ progress_bar.close()
+
logger.info(f"Reconstruction file saved to {output_path}")
@@ -59,7 +80,7 @@ def on_start() -> None:
progress_bar.disable = False
logger.info(f"Starting reconstruction for directory {input_path}")
- def on_completed(_path: Path) -> None:
+ def on_completed(_written: Tuple[Path, ...]) -> None:
logger.info(f"Reconstruction directory saved to {output_path}")
progress_bar.close()
@@ -82,13 +103,13 @@ def on_progress(
if task_status in (
TaskStatus.COMPLETED,
- TaskStatus.CANCELLED,
+ TaskStatus.CANCELED,
TaskStatus.FAILED,
):
progress_bar.close()
- def on_cancelled() -> None:
- logger.info("Reconstruction cancelled by user")
+ def on_canceled() -> None:
+ logger.info("Reconstruction canceled by user")
progress_bar.close()
def on_error(_exception: Exception) -> None:
@@ -96,8 +117,7 @@ def on_error(_exception: Exception) -> None:
converter = ReconstructionConverter(
config,
- input_path=input_path,
- is_file=False,
+ DirectoryConversion(directory=input_path, stems=_classic_setup(config)),
logger=null_logger,
)
@@ -105,7 +125,7 @@ def on_error(_exception: Exception) -> None:
on_start=on_start,
on_completed=on_completed,
on_progress=on_progress,
- on_cancelled=on_cancelled,
+ on_canceled=on_canceled,
on_error=on_error,
)
@@ -116,3 +136,8 @@ def on_error(_exception: Exception) -> None:
logger.info("Reconstruction interrupted by user")
finally:
progress_bar.close()
+
+
+def _classic_setup(config: Config) -> StemsConfig:
+ """The setup a single-source conversion runs under: one stem over every enabled channel."""
+ return StemsConfig.single_entry(list(config.generation.channels))
diff --git a/src/sampletones_core/structures/tree/node.py b/src/sampletones_core/structures/tree/node.py
index 6176c4d2d..d26492972 100644
--- a/src/sampletones_core/structures/tree/node.py
+++ b/src/sampletones_core/structures/tree/node.py
@@ -1,18 +1,34 @@
from __future__ import annotations
from pathlib import Path
-from typing import Optional
+from typing import Optional, Tuple
from anytree import Node
-from sampletones_core.constants.enums import LibraryGeneratorName
+from sampletones_core.constants.enums import GeneratorName
from sampletones_core.library import InstructionLibraryKey
from sampletones_core.reconstructions.converter.paths import ConfigDirectoryFields
from .type import NodeType
-class TreeNode(Node): # type: ignore[misc]
+class TreeNode(Node):
+ """A node of one of the application's trees, and the base every node kind derives from.
+
+ ``anytree`` lets a tree hold nodes of any type, so it states a node's relatives as untyped.
+ Every tree here is built from this class alone, which is what these declarations state: a
+ relative of a node is a node of ours, and the checker holds each reader to the attributes the
+ node kind it reached actually carries.
+ """
+
+ name: str
+ parent: Optional[TreeNode]
+ children: Tuple[TreeNode, ...]
+ path: Tuple[TreeNode, ...]
+ root: TreeNode
+ ancestors: Tuple[TreeNode, ...]
+ descendants: Tuple[TreeNode, ...]
+
def __init__(
self,
name: str,
@@ -118,7 +134,7 @@ class GeneratorNode(TreeNode):
def __init__(
self,
name: str,
- generator_name: LibraryGeneratorName,
+ generator_name: GeneratorName,
node_type: NodeType = NodeType.GENERATOR,
parent: Optional[TreeNode] = None,
) -> None:
diff --git a/src/sampletones_core/timers/__init__.py b/src/sampletones_core/timers/__init__.py
index f5e56c4c9..f4d684947 100644
--- a/src/sampletones_core/timers/__init__.py
+++ b/src/sampletones_core/timers/__init__.py
@@ -1,8 +1,9 @@
+from .arithmetic import frequency_to_timer, get_timer_ticks, timer_to_frequency
from .implementation.lfsr import LFSRTimer
from .implementation.phase import PhaseTimer
from .timer import Timer
from .types import TimerT, TimerTypeUnion, TimerUnion
-from .utils import get_frequency_table
+from .utils import get_frequency_table, get_timer_table
__all__ = [
"LFSRTimer",
@@ -11,5 +12,9 @@
"TimerT",
"TimerTypeUnion",
"TimerUnion",
+ "frequency_to_timer",
"get_frequency_table",
+ "get_timer_table",
+ "get_timer_ticks",
+ "timer_to_frequency",
]
diff --git a/src/sampletones_core/timers/arithmetic.py b/src/sampletones_core/timers/arithmetic.py
new file mode 100644
index 000000000..0d0da768d
--- /dev/null
+++ b/src/sampletones_core/timers/arithmetic.py
@@ -0,0 +1,51 @@
+from sampletones_core.constants.general import APU_CLOCK, MAX_TIMER, TIMER_CYCLE_DIVIDER
+
+
+def frequency_to_timer(frequency: float) -> int:
+ """The timer register value a channel sounds a frequency at.
+
+ The APU drives a channel's waveform by dividing its clock by ``16 * (timer + 1)``, so the
+ register value is that relation solved for the timer and rounded to the nearest whole
+ period. The result stays within the 11 bits the register offers, which is what holds the
+ pitches a channel reaches between ``MIN_FREQUENCY`` and ``MAX_FREQUENCY``.
+
+ Args:
+ frequency: The frequency in Hz to sound.
+
+ Returns:
+ int: The timer value, in ``[0, MAX_TIMER]``. A frequency of 0 Hz or below reads as 0.
+ """
+ if frequency <= 0:
+ return 0
+
+ timer = round(APU_CLOCK / (TIMER_CYCLE_DIVIDER * frequency)) - 1
+ return max(0, min(timer, MAX_TIMER))
+
+
+def get_timer_ticks(timer: int) -> int:
+ """The APU cycles one waveform period spans at a timer value.
+
+ Args:
+ timer: The timer register value.
+
+ Returns:
+ int: The cycles per period, ``16 * (timer + 1)``. A timer of 0 or below reads as 0,
+ the span a silent channel covers.
+ """
+ return (timer + 1) * TIMER_CYCLE_DIVIDER if timer > 0 else 0
+
+
+def timer_to_frequency(timer: int) -> float:
+ """The frequency a channel sounds at a timer register value.
+
+ This is the inverse of `frequency_to_timer`, and reading a timer back through it gives the
+ frequency the hardware actually produces — the nearest one the divider reaches, which a
+ rendered channel is tuned to.
+
+ Args:
+ timer: The timer register value.
+
+ Returns:
+ float: The frequency in Hz the divider produces for that timer.
+ """
+ return APU_CLOCK / (TIMER_CYCLE_DIVIDER * (timer + 1))
diff --git a/src/sampletones_core/timers/implementation/phase.py b/src/sampletones_core/timers/implementation/phase.py
index 8d6e2f422..72b2bff85 100644
--- a/src/sampletones_core/timers/implementation/phase.py
+++ b/src/sampletones_core/timers/implementation/phase.py
@@ -6,6 +6,7 @@
from sampletones_core.constants.general import APU_CLOCK
from sampletones_shared.types.data import Initials
+from ..arithmetic import frequency_to_timer, get_timer_ticks, timer_to_frequency
from ..timer import Timer
@@ -64,20 +65,8 @@ def generate_frame(self, save: bool = True) -> np.ndarray:
def initials(self) -> Tuple[Any, ...]:
return (self.phase,)
- @staticmethod
- def frequency_to_timer(frequency: float) -> int:
- if frequency <= 0:
- return 0
-
- timer = round(APU_CLOCK / (16 * frequency)) - 1
- return max(0, min(timer, 0x7FF))
-
- @staticmethod
- def get_timer_ticks(timer: int) -> int:
- return (timer + 1) * 16 if timer > 0 else 0
-
def round_frequency_by_timer(self) -> None:
- self._frequency = APU_CLOCK / (16 * (self._timer + 1))
+ self._frequency = timer_to_frequency(self._timer)
@property
def frequency(self) -> float:
@@ -86,11 +75,32 @@ def frequency(self) -> float:
@frequency.setter
def frequency(self, value: float) -> None:
self._frequency = value
- self._timer = self.frequency_to_timer(value)
- self._timer_ticks = self.get_timer_ticks(self._timer)
+ self._load(frequency_to_timer(value))
+
+ @property
+ def timer(self) -> int:
+ """The divider register value the oscillator is running at."""
+ return self._timer
+
+ @timer.setter
+ def timer(self, value: int) -> None:
+ self._load(value)
+
+ def _load(self, timer: int) -> None:
+ """Runs the oscillator at a divider value, and states the frequency that produces.
+
+ Both a frequency and a divider reach the oscillator here, so a caller naming either one
+ leaves the timer in the same state: the divider decides the waveform, and the frequency
+ the timer reports is the one that divider actually produces.
+
+ Args:
+ timer: The divider register value to run at.
+ """
+ self._timer = timer
+ self._timer_ticks = get_timer_ticks(timer)
self.round_frequency_by_timer()
- self._real_frequency = self.frequency * self.phase_increment
+ self._real_frequency = self._frequency * self.phase_increment
if self.reset_phase:
self.reset()
diff --git a/src/sampletones_core/timers/utils.py b/src/sampletones_core/timers/utils.py
index 2aed934a7..e46efce4b 100644
--- a/src/sampletones_core/timers/utils.py
+++ b/src/sampletones_core/timers/utils.py
@@ -1,8 +1,10 @@
from typing import Dict
from sampletones_core.configs import Config
-from sampletones_core.utils.frequencies import pitch_to_frequency
+from sampletones_shared.constants.music import LIMIT_MAX_PITCH, LIMIT_MIN_PITCH
+from sampletones_shared.music import Tuning
+from .arithmetic import frequency_to_timer
from .implementation.phase import PhaseTimer
@@ -11,15 +13,29 @@ def get_frequency_table(config: Config) -> Dict[int, float]:
sample_rate=config.library.sample_rate,
nes_frequency=config.library.nes_frequency,
)
+ tuning = config.library.tuning
frequencies = {}
for note in range(config.general.min_pitch, config.general.max_pitch + 1):
- frequency = pitch_to_frequency(
- note,
- config.library.a4_frequency,
- config.library.a4_pitch,
- )
- timer.frequency = frequency
+ timer.frequency = tuning.frequency(note)
frequencies[note] = timer.frequency
return frequencies
+
+
+def get_timer_table(tuning: Tuning) -> Dict[int, int]:
+ """The timer register value each pitch sounds at.
+
+ A pitch reaches the hardware as the divider producing the frequency nearest to it, and the
+ tuning is the whole of what decides which frequency that is — the same relation the
+ generators render from, stated in the terms the registers take. The table spans every pitch
+ the project sounds, so a stream naming any of them resolves.
+
+ Args:
+ tuning: Where concert pitch sits for the reconstruction being written.
+
+ Returns:
+ Dict[int, int]: The timer value for every pitch from ``LIMIT_MIN_PITCH`` to
+ ``LIMIT_MAX_PITCH``.
+ """
+ return {pitch: frequency_to_timer(tuning.frequency(pitch)) for pitch in range(LIMIT_MIN_PITCH, LIMIT_MAX_PITCH + 1)}
diff --git a/src/sampletones_core/timing/__init__.py b/src/sampletones_core/timing/__init__.py
index 11b4d4cab..bd4a5a5b0 100644
--- a/src/sampletones_core/timing/__init__.py
+++ b/src/sampletones_core/timing/__init__.py
@@ -1,13 +1,18 @@
+from .bounds import MAX_TICKS_PER_ROW, MIN_TICKS_PER_ROW
from .clock import TickClock
from .distribution import distribute_by_halving, distribute_proportionally
from .groove import Groove, calculate_groove
-from .metre import Metre
+from .meter import Meter
from .rate import RowRate
+from .song import SongTiming
__all__ = [
+ "MAX_TICKS_PER_ROW",
+ "MIN_TICKS_PER_ROW",
"Groove",
- "Metre",
+ "Meter",
"RowRate",
+ "SongTiming",
"TickClock",
"calculate_groove",
"distribute_by_halving",
diff --git a/src/sampletones_core/timing/bounds.py b/src/sampletones_core/timing/bounds.py
new file mode 100644
index 000000000..8c3da006f
--- /dev/null
+++ b/src/sampletones_core/timing/bounds.py
@@ -0,0 +1,15 @@
+from math import ceil
+from typing import Final
+
+from sampletones_core.timing.rate import RowRate
+from sampletones_shared.constants.nes import MAX_NES_FREQUENCY
+from sampletones_shared.constants.project import MAX_SPEED, MIN_TEMPO
+
+MIN_TICKS_PER_ROW: Final[int] = 1
+MAX_TICKS_PER_ROW: Final[int] = ceil(
+ RowRate.from_parameters(
+ tempo=MIN_TEMPO,
+ speed=MAX_SPEED,
+ nes_frequency=MAX_NES_FREQUENCY,
+ ).ticks_per_row
+)
diff --git a/src/sampletones_core/timing/groove.py b/src/sampletones_core/timing/groove.py
index 368ff36f7..541ff32bc 100644
--- a/src/sampletones_core/timing/groove.py
+++ b/src/sampletones_core/timing/groove.py
@@ -7,7 +7,7 @@
distribute_by_halving,
distribute_proportionally,
)
-from sampletones_core.timing.metre import Metre
+from sampletones_core.timing.meter import Meter
from sampletones_core.timing.rate import RowRate
HALF: Final[Fraction] = Fraction(1, 2)
@@ -19,7 +19,7 @@ class Groove:
An engine that takes one speed value per row reaches a fractional row rate by varying
that value from row to row, which is how a tempo its speed column alone cannot state
- still comes out right on average. The variation is placed by metre, so the longer rows
+ still comes out right on average. The variation is placed by meter, so the longer rows
land on the bar, then the beat, then the subdivisions inside a beat.
Attributes:
@@ -54,7 +54,7 @@ def _pattern_ticks(
"""Rounds a pattern's exact tick count to the nearest integer within the engine's speed range.
Rounding once, on the pattern, is what makes the pattern's duration the closest the
- engine reaches; the metre then decides which rows carry the difference. Bounding the
+ engine reaches; the meter then decides which rows carry the difference. Bounding the
pattern total rather than each row keeps every row inside the range as a consequence,
since a proportional split yields only the floor and the ceiling of the average.
@@ -76,7 +76,7 @@ def _pattern_ticks(
def calculate_groove(
rate: RowRate,
- metre: Metre,
+ meter: Meter,
*,
minimum_ticks: int,
maximum_ticks: int,
@@ -89,7 +89,7 @@ def calculate_groove(
Args:
rate: The exact ticks one row lasts.
- metre: The pattern's length and its beat and bar grouping.
+ meter: The pattern's length and its beat and bar grouping.
minimum_ticks: The fewest ticks the engine holds a row for.
maximum_ticks: The most ticks the engine holds a row for.
@@ -98,11 +98,11 @@ def calculate_groove(
"""
total = _pattern_ticks(
rate,
- metre.rows,
+ meter.rows,
minimum_ticks=minimum_ticks,
maximum_ticks=maximum_ticks,
)
- bars = metre.spans
+ bars = meter.spans
bar_lengths = tuple(sum(beats) for beats in bars)
ticks: List[int] = []
diff --git a/src/sampletones_core/timing/metre.py b/src/sampletones_core/timing/meter.py
similarity index 96%
rename from src/sampletones_core/timing/metre.py
rename to src/sampletones_core/timing/meter.py
index 8b80eee91..b2e5a287c 100644
--- a/src/sampletones_core/timing/metre.py
+++ b/src/sampletones_core/timing/meter.py
@@ -7,7 +7,7 @@
@dataclass(frozen=True)
-class Metre:
+class Meter:
"""The row grouping a pattern is felt in: its length, its beat, and the bar above it.
``first_highlight`` is the beat, the unit an actual tempo is read from, and
@@ -41,8 +41,8 @@ def __post_init__(self) -> None:
raise ValueError(f"second_highlight must be at least 1, got {self.second_highlight}")
@classmethod
- def from_settings(cls, settings: ProjectSettings, *, rows: int) -> Metre:
- """Reads the metre a project states, over a pattern of ``rows`` rows.
+ def from_settings(cls, settings: ProjectSettings, *, rows: int) -> Meter:
+ """Reads the meter a project states, over a pattern of ``rows`` rows.
The project holds the two highlights while the song holds the pattern length, so
the row count arrives beside the settings.
diff --git a/src/sampletones_core/timing/rate.py b/src/sampletones_core/timing/rate.py
index f02103858..a3c032418 100644
--- a/src/sampletones_core/timing/rate.py
+++ b/src/sampletones_core/timing/rate.py
@@ -21,7 +21,7 @@ class RowRate:
The ratio is held exact, since a row rate is fractional for most tempi and the
fraction is what a groove distributes across a pattern's rows.
- A row rate reads as a tempo in beats per minute once a metre says how many rows one
+ A row rate reads as a tempo in beats per minute once a meter says how many rows one
beat spans::
beats_per_minute = 60 * nes_frequency / (ticks_per_row * first_highlight)
diff --git a/src/sampletones_application/logic/sequencer/playback/synthesizer/timing.py b/src/sampletones_core/timing/song.py
similarity index 69%
rename from src/sampletones_application/logic/sequencer/playback/synthesizer/timing.py
rename to src/sampletones_core/timing/song.py
index 96c72dd78..fa3250c18 100644
--- a/src/sampletones_application/logic/sequencer/playback/synthesizer/timing.py
+++ b/src/sampletones_core/timing/song.py
@@ -1,12 +1,11 @@
from dataclasses import dataclass
from typing import Self
-from sampletones_application.constants.playback import (
- MAX_TICKS_PER_ROW,
- MIN_TICKS_PER_ROW,
-)
from sampletones_core.project import Project
-from sampletones_core.timing import Groove, Metre, RowRate, calculate_groove
+from sampletones_core.timing.bounds import MAX_TICKS_PER_ROW, MIN_TICKS_PER_ROW
+from sampletones_core.timing.groove import Groove, calculate_groove
+from sampletones_core.timing.meter import Meter
+from sampletones_core.timing.rate import RowRate
@dataclass(frozen=True)
@@ -15,18 +14,18 @@ class SongTiming:
Attributes:
rate: The exact ticks one row lasts under the project's tempo, speed and tick rate.
- metre: The pattern length and the beat and bar grouping the ticks are spread over.
+ meter: The pattern length and the beat and bar grouping the ticks are spread over.
"""
rate: RowRate
- metre: Metre
+ meter: Meter
@classmethod
def from_project(cls, project: Project) -> Self:
"""Reads the timing a project plays at, taking the pattern length from its song."""
return cls(
rate=RowRate.from_settings(project.settings),
- metre=Metre.from_settings(
+ meter=Meter.from_settings(
project.settings,
rows=project.song.rows_per_pattern,
),
@@ -35,13 +34,13 @@ def from_project(cls, project: Project) -> Self:
def groove(self) -> Groove:
"""Spreads the row rate across a pattern's rows.
- Playback follows whatever tempo the project states, so the one bound it sets is that
- every row lasts at least a tick and keeps sounding; the ceiling is the fastest row the
+ A song runs at whatever tempo the project states, so the one bound that applies is that
+ every row lasts at least a tick and keeps sounding; the ceiling is the slowest row the
settings can ask for, which leaves the groove free to realize the rate exactly.
"""
return calculate_groove(
self.rate,
- self.metre,
+ self.meter,
minimum_ticks=MIN_TICKS_PER_ROW,
maximum_ticks=MAX_TICKS_PER_ROW,
)
diff --git a/src/sampletones_core/trackers/format.py b/src/sampletones_core/trackers/format.py
deleted file mode 100644
index 625f55e5c..000000000
--- a/src/sampletones_core/trackers/format.py
+++ /dev/null
@@ -1,9 +0,0 @@
-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/registry.py b/src/sampletones_core/trackers/registry.py
deleted file mode 100644
index c979a926f..000000000
--- a/src/sampletones_core/trackers/registry.py
+++ /dev/null
@@ -1,23 +0,0 @@
-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
deleted file mode 100644
index 6b75932c8..000000000
--- a/src/sampletones_core/trackers/request.py
+++ /dev/null
@@ -1,51 +0,0 @@
-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/types/feature.py b/src/sampletones_core/types/feature.py
deleted file mode 100644
index 3e257f4d2..000000000
--- a/src/sampletones_core/types/feature.py
+++ /dev/null
@@ -1,8 +0,0 @@
-from typing import Dict, Union
-
-import numpy as np
-
-from sampletones_core.constants.enums import FeatureKey
-
-FeatureValue = Union[int, np.ndarray]
-FeatureMap = Dict[FeatureKey, FeatureValue]
diff --git a/src/sampletones_core/utils/display.py b/src/sampletones_core/utils/display.py
index fe330b6ee..7dc169af3 100644
--- a/src/sampletones_core/utils/display.py
+++ b/src/sampletones_core/utils/display.py
@@ -1,9 +1,12 @@
from typing import Final, Optional, Union
-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.constants.enums import ChannelName
+from sampletones_core.features import transposed_reference
+from sampletones_core.project.voices.note_off import NoteOff
+from sampletones_core.project.voices.note_on import NoteOn
+from sampletones_core.project.voices.voice import VoiceUnion
from sampletones_core.structures import IdentifiedCollection
+from sampletones_core.utils.pitch_kind import channel_pitch_kind
from sampletones_shared.constants.symbols import MINUS, PLUS
DEFAULT_DISPLAY_LENGTH: Final[int] = 2
@@ -32,43 +35,69 @@ def display_id(value: Optional[int]) -> str:
return display_value(value, hexadecimal=True)
-def display_sample(
+def display_voice(
*,
- samples: IdentifiedCollection[Sample],
- sample_id: Optional[str] = None,
+ voices: IdentifiedCollection[VoiceUnion],
+ voice_id: Optional[str] = None,
) -> str:
"""
- Render a sample reference as its current list position (not its uuid).
+ Render a voice reference as its current list position (not its uuid).
"""
- if sample_id is not None and samples.get(sample_id) is not None:
- return display_id(samples.get_index(sample_id))
+ if voice_id is not None and voices.get(voice_id) is not None:
+ return display_id(voices.get_index(voice_id))
return display_id(None)
-def display_sample_label(position: int, name: str) -> str:
- """Render an instrument's list label as ``": "`` (e.g. ``"1A: Bass"``)."""
+def display_voice_label(position: int, name: str) -> str:
+ """Render a voice's list label as ``": "`` (e.g. ``"1A: Bass"``)."""
return f"{display_id(position)}: {name}"
def display_command(
- samples: IdentifiedCollection[Sample],
- command: Optional[Union[Instrument, NoteOff]],
+ voices: IdentifiedCollection[VoiceUnion],
+ command: Optional[Union[NoteOn, NoteOff]],
) -> str:
- """Render a row's note-column command: a sample's list position, ``--`` for note-off, or ``..``."""
+ """Render a row's note-column command: a voice's list position, ``--`` for note-off, or ``..``."""
match command:
case NoteOff():
return NOTE_OFF
- case Instrument():
- return display_sample(samples=samples, sample_id=command.sample_id)
+ case NoteOn():
+ return display_voice(voices=voices, voice_id=command.voice_id)
case None:
- return display_sample(samples=samples, sample_id=None)
+ return display_voice(voices=voices, voice_id=None)
def display_volume(value: Optional[int]) -> str:
return display_value(value, length=1, hexadecimal=True)
+def display_note(
+ value: Optional[int],
+ *,
+ channel_name: ChannelName,
+ reference: int,
+) -> str:
+ """Render a pitch column as the note it sounds, or ``...`` for an empty one.
+
+ The note is where the voice's reference lands once the row's transpose has moved it, so the
+ grid prints the note the channel plays. The noise channel names its period the same way
+ FamiTracker does.
+
+ Args:
+ value: The semitones the row states, or ``None`` for an empty cell.
+ channel_name: The channel the row sits on.
+ reference: The value the voice is measured against on that channel.
+
+ Returns:
+ str: The note name, three characters wide like every other reading of the column.
+ """
+ if value is None:
+ return NOTE_BLANK
+
+ return channel_pitch_kind(channel_name).to_name(transposed_reference(channel_name, reference, value))
+
+
def display_transpose(value: Optional[int]) -> str:
"""Render a transpose as a signed two-digit offset, or ``...`` for an empty one.
diff --git a/src/sampletones_core/utils/frequencies.py b/src/sampletones_core/utils/frequencies.py
index 4423c0cdb..83be0b003 100644
--- a/src/sampletones_core/utils/frequencies.py
+++ b/src/sampletones_core/utils/frequencies.py
@@ -1,35 +1,14 @@
-import numpy as np
-
from sampletones_core.constants.general import (
- A4_FREQUENCY,
- A4_PITCH,
- LIMIT_MAX_PITCH,
- LIMIT_MIN_PITCH,
MAX_PERIOD,
MAX_PITCH,
MIN_PITCH,
NOISE_PERIODS,
NOTE_NAMES,
+ NUM_PERIODS,
)
+from sampletones_shared.constants.music import OCTAVE_OFFSET, OCTAVE_SEMITONES
from sampletones_shared.utils.arrays import clamp
-
-
-def validate_pitch(pitch: int) -> None:
- """
- Validates that a pitch value is an integer within the range 24-127.
-
- Args:
- pitch: The pitch value to validate.
-
- Raises:
- TypeError: If pitch is not an integer.
- ValueError: If pitch is outside the range 24-127.
- """
- if not isinstance(pitch, int):
- raise TypeError("Pitch must be an integer value")
-
- if not LIMIT_MIN_PITCH <= pitch <= LIMIT_MAX_PITCH:
- raise ValueError(f"Pitch must be in the range {LIMIT_MIN_PITCH}-{LIMIT_MAX_PITCH}")
+from sampletones_shared.utils.frequencies import validate_pitch
def is_pitch_valid(pitch: int) -> bool:
@@ -45,27 +24,6 @@ def is_pitch_valid(pitch: int) -> bool:
return MIN_PITCH <= pitch <= MAX_PITCH
-def validate_frequency(frequency: float) -> None:
- """
- Validates that a frequency value is a positive finite number.
-
- Args:
- frequency: The frequency value to validate.
-
- Raises:
- TypeError: If frequency is not a numeric type.
- ValueError: If frequency is not a positive finite number.
- """
- if not isinstance(frequency, (int, float)):
- raise TypeError("Frequency must be a numeric value")
-
- if np.isinf(frequency) or np.isnan(frequency):
- raise ValueError("Frequency must be a positive finite number")
-
- if frequency <= 0:
- raise ValueError("Frequency must be a positive value")
-
-
def validate_period(period: int) -> None:
"""
Validates that a period value is an integer within the range 0-15.
@@ -84,99 +42,14 @@ def validate_period(period: int) -> None:
raise ValueError(f"Period must be in the range 0-{MAX_PERIOD}")
-def pitch_to_frequency(
- pitch: int,
- a4_frequency: float = A4_FREQUENCY,
- a4_pitch: int = A4_PITCH,
-) -> float:
- """
- Converts a MIDI-style pitch value to its corresponding frequency in Hz.
-
- Uses the equal temperament tuning system where each semitone is separated
- by a factor of 2^(1/12).
+def transpose_pitch(pitch: int, transpose: int) -> int:
+ """The pitch a transpose reaches, held inside the range the channels play."""
+ return clamp_pitch(pitch + transpose)
- Args:
- pitch: The MIDI pitch number (24-127, where 69 is typically A4).
- a4_frequency: The reference frequency for A4 in Hz. Defaults to 440.0 Hz.
- a4_pitch: The MIDI pitch number for A4. Defaults to 69.
- Returns:
- The frequency in Hz corresponding to the given pitch.
-
- Raises:
- TypeError: If pitch is not an integer.
- TypeError: If a4_frequency is not a numeric type.
- TypeError: If a4_pitch is not an integer.
- ValueError: If pitch is outside the range 24-127.
- ValueError: If a4_pitch is outside the range 24-127.
- ValueError: If a4_frequency is not a positive finite number.
- ValueError: If calculated frequency is not a positive finite number.
-
- Examples:
- >>> pitch_to_frequency(69) # A4
- 440.0
- >>> pitch_to_frequency(57) # A3 (one octave below A4)
- 220.0
- >>> pitch_to_frequency(60) # Middle C (C4)
- 261.6255653005986
- >>> pitch_to_frequency(69, a4_frequency=432.0) # A4 with different tuning
- 432.0
- """
- validate_pitch(pitch)
- validate_pitch(a4_pitch)
- validate_frequency(a4_frequency)
-
- frequency: float = a4_frequency * (2 ** ((pitch - a4_pitch) / 12))
- validate_frequency(frequency)
- return frequency
-
-
-def frequency_to_pitch(
- frequency: float,
- a4_frequency: float = A4_FREQUENCY,
- a4_pitch: int = A4_PITCH,
-) -> int:
- """
- Converts a frequency in Hz to the nearest MIDI-style pitch value.
-
- Uses logarithmic conversion based on the equal temperament tuning system.
- Returns 0 for frequencies at or below 0 Hz.
-
- Args:
- frequency: The frequency in Hz to convert.
- a4_frequency: The reference frequency for A4 in Hz. Defaults to 440.0 Hz.
- a4_pitch: The MIDI pitch number for A4. Defaults to 69.
-
- Returns:
- The nearest integer MIDI pitch number.
-
- Raises:
- TypeError: If frequency is not a numeric type.
- TypeError: If a4_frequency is not a numeric type.
- TypeError: If a4_pitch is not an integer.
- ValueError: If frequency is not a positive finite number.
- ValueError: If a4_frequency is not a positive finite number.
- ValueError: If calculated pitch is outside the range 24-127.
-
- Examples:
- >>> frequency_to_pitch(440.0) # A4
- 69
- >>> frequency_to_pitch(880.0) # A5
- 81
- >>> frequency_to_pitch(261.63) # ~middle C
- 60
- >>> frequency_to_pitch(0.0) # invalid frequency
- Traceback (most recent call last):
- ...
- ValueError: Frequency must be a positive value
- """
- validate_frequency(frequency)
- validate_frequency(a4_frequency)
- validate_pitch(a4_pitch)
-
- pitch: int = round(a4_pitch + 12 * (np.log2(frequency / a4_frequency)))
- validate_pitch(pitch)
- return pitch
+def transpose_period(period: int, transpose: int) -> int:
+ """The period a transpose reaches, walked around the sixteen the hardware offers."""
+ return (period + transpose) % NUM_PERIODS
def pitch_to_name(pitch: int, transpose: int = 0) -> str:
@@ -223,8 +96,8 @@ def pitch_to_name(pitch: int, transpose: int = 0) -> str:
pitch += transpose
validate_pitch(pitch)
- octave = (pitch // 12) - 2
- note_index = pitch % 12
+ octave = pitch // OCTAVE_SEMITONES - OCTAVE_OFFSET
+ note_index = pitch % OCTAVE_SEMITONES
return f"{NOTE_NAMES[note_index]}{octave}"
diff --git a/src/sampletones_core/utils/pitch_kind.py b/src/sampletones_core/utils/pitch_kind.py
index 05e16184b..4f561fe29 100644
--- a/src/sampletones_core/utils/pitch_kind.py
+++ b/src/sampletones_core/utils/pitch_kind.py
@@ -1,7 +1,9 @@
from dataclasses import dataclass
from typing import Callable, Mapping
+from sampletones_core.constants.enums import ChannelName
from sampletones_core.constants.general import MAX_PERIOD, MAX_PITCH, MIN_PITCH
+from sampletones_core.features import speaks_in_periods
from sampletones_core.utils.frequencies import (
SANITIZED_NAME_TO_PERIOD,
SANITIZED_NAME_TO_PITCH,
@@ -54,3 +56,15 @@ def from_text(self, text: str, fallback: int) -> int:
sanitize=sanitize_period,
sanitized_name_to_value=SANITIZED_NAME_TO_PERIOD,
)
+
+
+def channel_pitch_kind(channel_name: ChannelName) -> PitchValueKind:
+ """The terms a channel states its pitch-like values in.
+
+ Args:
+ channel_name: The channel being read.
+
+ Returns:
+ PitchValueKind: The noise channel's periods, or the semitones the others name.
+ """
+ return PERIOD_VALUE_KIND if speaks_in_periods(channel_name) else PITCH_VALUE_KIND
diff --git a/src/sampletones_player/__init__.py b/src/sampletones_player/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/src/sampletones_player/builder.py b/src/sampletones_player/builder.py
new file mode 100644
index 000000000..4dbcf3743
--- /dev/null
+++ b/src/sampletones_player/builder.py
@@ -0,0 +1,233 @@
+from typing import Dict, Final, Mapping, Optional, Sequence, Tuple
+
+from sampletones_core.constants.enums import ChannelName
+from sampletones_core.exporters.maps import CHANNEL_TO_EXPORTER_MAP
+from sampletones_core.exports.request import InstrumentExport, SampleExport
+from sampletones_core.instructions import InstructionUnion
+from sampletones_core.performance import (
+ WalkReporter,
+ song_instructions,
+)
+from sampletones_core.project.project import Project
+from sampletones_core.project.tuning import tuning_from_project
+from sampletones_core.reconstructions import Reconstruction
+from sampletones_core.timers.utils import get_timer_table
+from sampletones_player.clock.schedule import PlaySchedule
+from sampletones_player.compression.dictionary.phrase import Phrase
+from sampletones_player.compression.pitch import PitchTable
+from sampletones_player.compression.progress.report import CodecReporter
+from sampletones_player.compression.seeds import phrases_from_project
+from sampletones_player.registers.channel import channel_registers
+from sampletones_player.registers.streams import ChannelStreams
+from sampletones_player.song import Song
+from sampletones_shared.utils.progress import silent_reporter
+
+SONG_START: Final[int] = 0
+NO_SEEDS: Final[Tuple[Phrase, ...]] = ()
+
+
+def streams_from_instructions(
+ instructions: Mapping[ChannelName, Sequence[InstructionUnion]],
+ timer_table: Dict[int, int],
+) -> ChannelStreams:
+ """Encodes every channel's instructions into the register values its ticks write.
+
+ The song covers all four channels, so a channel the mapping leaves out rests through it,
+ the same as one whose stream describes no frame.
+
+ Args:
+ instructions: The stream each channel carries.
+ timer_table: The timer register value each pitch sounds at.
+
+ Returns:
+ ChannelStreams: The four streams the driver plays.
+
+ Raises:
+ TypeError: If a channel's stream holds an instruction another channel sounds.
+ """
+ return ChannelStreams(
+ pulse1=channel_registers(ChannelName.PULSE1, instructions, timer_table),
+ pulse2=channel_registers(ChannelName.PULSE2, instructions, timer_table),
+ triangle=channel_registers(ChannelName.TRIANGLE, instructions, timer_table),
+ noise=channel_registers(ChannelName.NOISE, instructions, timer_table),
+ )
+
+
+def song_from_reconstruction(
+ reconstruction: Reconstruction,
+ loop_tick: Optional[int],
+ report: CodecReporter = silent_reporter,
+) -> Song:
+ """Builds the song the console plays a reconstruction as.
+
+ The reconstruction's own configuration carries both halves of the answer: the pitches it was
+ built against become timers through the very table its generators render from, and the rate
+ it was built at becomes the schedule the driver re-clocks the streams by.
+
+ A reconstruction sounds each of its slices once, so the search fills the dictionary from what
+ the streams themselves repeat.
+
+ Args:
+ reconstruction: The reconstruction to play.
+ loop_tick: The tick the song returns to once it ends, or ``None`` where it stops there.
+ report: Hears what the codec holds each time it looks up, and answers whether the
+ compression goes on.
+
+ Returns:
+ Song: The streams, the clock and the loop point as the player holds them.
+
+ Raises:
+ OperationCanceled: If ``report`` withdraws the compression.
+ TypeError: If a channel's stream holds an instruction another channel sounds.
+ ValueError: If ``loop_tick`` lies outside the song's ticks.
+ """
+ tuning = reconstruction.config.tuning
+ return Song.from_streams(
+ streams=streams_from_instructions(
+ reconstruction.instructions,
+ get_timer_table(tuning),
+ ),
+ pitches=PitchTable.from_tuning(tuning),
+ schedule=PlaySchedule.from_parameters(reconstruction.config.nes_frequency),
+ loop_tick=loop_tick,
+ seeds=NO_SEEDS,
+ report=report,
+ )
+
+
+def instructions_from_instruments(
+ instruments: Sequence[InstrumentExport],
+) -> Dict[ChannelName, Sequence[InstructionUnion]]:
+ """Reads every channel slice of an export request back as instructions.
+
+ A request describes each slice as the envelopes an instrument carries, which is the form a
+ tracker reads it in. The console sounds instructions instead, so each slice is walked back
+ through the exporter belonging to the channel it was reconstructed for.
+
+ Args:
+ instruments: The slices to sound.
+
+ Returns:
+ Dict[ChannelName, Sequence[InstructionUnion]]: The stream each channel carries.
+
+ Raises:
+ ValueError: If two slices name the same channel, which the console sounds one of.
+ """
+ instructions: Dict[ChannelName, Sequence[InstructionUnion]] = {}
+ for instrument in instruments:
+ if instrument.channel in instructions:
+ raise ValueError(f"Channel '{instrument.channel}' carries two slices, and the console sounds one")
+
+ exporter = CHANNEL_TO_EXPORTER_MAP[instrument.channel]
+ instructions[instrument.channel] = exporter.from_features(instrument.features)
+
+ return instructions
+
+
+def loop_tick_from_instruments(instruments: Sequence[InstrumentExport]) -> Optional[int]:
+ """The tick a request's song returns to once it ends.
+
+ A song repeats from its first tick where every slice it carries repeats, and ends at its
+ last tick where any slice holds its envelopes out instead.
+
+ Args:
+ instruments: The slices the song carries.
+
+ Returns:
+ Optional[int]: The tick to return to, or ``None`` where the song stops at its end.
+ """
+ if instruments and all(_repeats(instrument) for instrument in instruments):
+ return SONG_START
+
+ return None
+
+
+def _repeats(instrument: InstrumentExport) -> bool:
+ """Whether a slice circles rather than holding its envelopes out."""
+ return any(envelope.loops for envelope in instrument.features.envelopes.values())
+
+
+def song_from_sample(
+ request: SampleExport,
+ report: CodecReporter = silent_reporter,
+) -> Song:
+ """Builds the song the console plays an export request as.
+
+ Every slice sounds at once on the channel it was reconstructed for, and the request states
+ both halves of what that takes: the tuning its pitches are measured from, and the rate its
+ envelopes advance at, which the driver re-clocks to the rate the console calls it at.
+
+ The request sounds each of its slices once, so the search fills the dictionary from what the
+ streams themselves repeat.
+
+ Args:
+ request: The slices to play together.
+ report: Hears what the codec holds each time it looks up, and answers whether the
+ compression goes on.
+
+ Returns:
+ Song: The streams, the clock and the loop point as the player holds them.
+
+ Raises:
+ OperationCanceled: If ``report`` withdraws the compression.
+ TypeError: If a channel's stream holds an instruction another channel sounds.
+ ValueError: If two slices name the same channel.
+ """
+ return Song.from_streams(
+ streams=streams_from_instructions(
+ instructions_from_instruments(request.instruments),
+ get_timer_table(request.tuning),
+ ),
+ pitches=PitchTable.from_tuning(request.tuning),
+ schedule=PlaySchedule.from_parameters(request.nes_frequency),
+ loop_tick=loop_tick_from_instruments(request.instruments),
+ seeds=NO_SEEDS,
+ report=report,
+ )
+
+
+def song_from_project(
+ project: Project,
+ loop_tick: Optional[int],
+ report: CodecReporter = silent_reporter,
+ walk: WalkReporter = silent_reporter,
+) -> Song:
+ """Builds the song the console plays a whole project as.
+
+ The project's song is played out row by row into the instructions each channel sounds, so
+ what reaches the console is the arrangement itself rather than one reconstruction: the same
+ walk the sequencer sounds a song through, read as register values instead of audio. The
+ project states both the rate the driver re-clocks those ticks by and, through the samples it
+ holds, the tuning its pitches become timers under.
+
+ A row plays a sample the project already holds, so the samples themselves seed the dictionary
+ and every row naming one reaches the stream as a token naming that entry.
+
+ Args:
+ project: The project whose song is played.
+ loop_tick: The tick the song returns to once it ends, or ``None`` where it stops there.
+ report: Hears what the codec holds each time it looks up, and answers whether the
+ compression goes on.
+ walk: Hears how far the song has been played out, and answers whether the walk goes on.
+
+ Returns:
+ Song: The streams, the clock and the loop point as the player holds them.
+
+ Raises:
+ OperationCanceled: If ``report`` or ``walk`` withdraws the run.
+ TypeError: If a channel's stream holds an instruction another channel sounds.
+ ValueError: If ``loop_tick`` lies outside the song's ticks, or the project's samples were
+ reconstructed against tunings that differ.
+ """
+ tuning = tuning_from_project(project)
+ return Song.from_streams(
+ streams=streams_from_instructions(
+ song_instructions(project, walk),
+ get_timer_table(tuning),
+ ),
+ pitches=PitchTable.from_tuning(tuning),
+ schedule=PlaySchedule.from_parameters(project.settings.nes_frequency),
+ loop_tick=loop_tick,
+ seeds=phrases_from_project(project, tuning),
+ report=report,
+ )
diff --git a/src/sampletones_player/clock/__init__.py b/src/sampletones_player/clock/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/src/sampletones_player/clock/schedule.py b/src/sampletones_player/clock/schedule.py
new file mode 100644
index 000000000..82ee27363
--- /dev/null
+++ b/src/sampletones_player/clock/schedule.py
@@ -0,0 +1,153 @@
+from __future__ import annotations
+
+from fractions import Fraction
+from functools import cached_property
+from math import floor
+
+from pydantic import BaseModel, ConfigDict, Field
+
+from sampletones_player.clock.step import FixedPointStep
+from sampletones_player.specification.clock import (
+ FIXED_POINT_BITS,
+ FIXED_POINT_SCALE,
+ MAX_STEP_WHOLE,
+ NTSC_FRAME_RATE,
+)
+
+
+class PlaySchedule(BaseModel):
+ """The engine ticks each play call advances a stream by, counted the way the console counts them.
+
+ An NSF asks the console to call its play routine at one fixed rate, and a reconstruction is
+ built at whatever rate its ``nes_frequency`` states. The two meet here: the schedule states how
+ far the stream stands from its start after any number of calls, and the driver follows it by
+ adding :attr:`fixed_point_step` to an accumulator and advancing by the whole ticks that fall
+ out. One data set plays at every stream rate, and a stream slower than the play rate simply
+ stands still on the calls between its ticks.
+
+ Every answer about where the stream stands comes from that same rounded step, so the schedule
+ states what the assembly does rather than what an unrounded clock would do. The exact rate
+ stays as :attr:`ticks_per_play_call`, which is what :meth:`maximum_drift` measures against.
+
+ This is the rule :class:`~sampletones_core.timing.clock.TickClock` applies one level down:
+ spread the fractional part across consecutive units so the running total tracks the exact
+ clock. There it is audio samples per engine tick; here it is engine ticks per play call.
+
+ Initialization leaves the stream on tick 0, and the play call at index ``play_call`` leaves it
+ on tick ``ticks_at(play_call + 1)``.
+
+ Attributes:
+ ticks_per_play_call: The exact ticks one play call advances the stream by.
+ """
+
+ model_config = ConfigDict(extra="forbid", frozen=True)
+
+ ticks_per_play_call: Fraction = Field(..., gt=0, le=MAX_STEP_WHOLE)
+
+ @classmethod
+ def from_parameters(cls, nes_frequency: int) -> PlaySchedule:
+ """Derives the schedule a stream built at ``nes_frequency`` plays on.
+
+ The console calls the play routine once a video frame, so the rate a stream is read at
+ is its own rate measured against `NTSC_FRAME_RATE`. The header states that same rate as
+ the period it asks for, so a player honoring the field and one driving from the frame
+ itself run the stream at the speed it was built at.
+
+ Args:
+ nes_frequency: The engine tick rate the reconstruction was built at, in Hz.
+
+ Returns:
+ PlaySchedule: The exact ticks one play call advances the stream by.
+
+ Raises:
+ ValueError: If ``nes_frequency`` is below 1, or asks for more ticks per call than the
+ step's whole byte holds.
+ """
+ if nes_frequency < 1:
+ raise ValueError(f"nes_frequency must be at least 1, got {nes_frequency}")
+
+ return cls(ticks_per_play_call=Fraction(nes_frequency) / NTSC_FRAME_RATE)
+
+ def ticks_at(self, play_calls: int) -> int:
+ """The tick the stream stands on once ``play_calls`` calls have been made.
+
+ Counts the way the driver counts: the rounded step added once per call, with the whole
+ ticks read off the top of the running total. The console has only this arithmetic, so it
+ is the schedule a song plays on, and :meth:`exact_ticks_at` is what it is measured against.
+
+ Args:
+ play_calls: How many play calls have been made, at least 0.
+
+ Returns:
+ int: The tick's index, within one tick of where the exact clock puts the stream.
+
+ Raises:
+ ValueError: If ``play_calls`` is negative.
+ """
+ if play_calls < 0:
+ raise ValueError(f"play_calls must be at least 0, got {play_calls}")
+
+ return (play_calls * self.fixed_point_step.value) >> FIXED_POINT_BITS
+
+ def exact_ticks_at(self, play_calls: int) -> int:
+ """The tick an unrounded clock puts the stream on once ``play_calls`` calls have been made.
+
+ Args:
+ play_calls: How many play calls have been made, at least 0.
+
+ Returns:
+ int: The tick's index, held in exact arithmetic.
+
+ Raises:
+ ValueError: If ``play_calls`` is negative.
+ """
+ if play_calls < 0:
+ raise ValueError(f"play_calls must be at least 0, got {play_calls}")
+
+ return floor(self.ticks_per_play_call * play_calls)
+
+ def advance_at(self, play_call: int) -> int:
+ """The ticks the stream advances by during the call at ``play_call``.
+
+ Taking the difference of two cumulative counts is what makes a run of advances sum to the
+ exact span it covers, however the fraction falls. This is the carry the driver reads out of
+ its accumulator, and a carry of zero marks a call the stream holds its tick through.
+
+ Args:
+ play_call: The call's position in the run, counted from 0.
+
+ Returns:
+ int: The ticks that call advances by.
+
+ Raises:
+ ValueError: If ``play_call`` is negative.
+ """
+ return self.ticks_at(play_call + 1) - self.ticks_at(play_call)
+
+ @cached_property
+ def fixed_point_step(self) -> FixedPointStep:
+ """The exact step rounded to the nearest unit the driver's accumulator counts in.
+
+ Every answer the schedule gives counts in this step, so it is computed once and held.
+ """
+ whole, fraction = divmod(round(self.ticks_per_play_call * FIXED_POINT_SCALE), FIXED_POINT_SCALE)
+ return FixedPointStep(whole=whole, fraction=fraction)
+
+ def maximum_drift(self, play_calls: int) -> int:
+ """The furthest the driver's schedule stands from the exact one across a run of calls.
+
+ Args:
+ play_calls: How many calls the run covers, at least 0.
+
+ Returns:
+ int: The largest gap in ticks, measured at every call in the run.
+
+ Raises:
+ ValueError: If ``play_calls`` is negative.
+ """
+ if play_calls < 0:
+ raise ValueError(f"play_calls must be at least 0, got {play_calls}")
+
+ return max(
+ abs(self.ticks_at(play_call) - self.exact_ticks_at(play_call)) for play_call in range(play_calls + 1)
+ )
diff --git a/src/sampletones_player/clock/step.py b/src/sampletones_player/clock/step.py
new file mode 100644
index 000000000..4c45ea8b4
--- /dev/null
+++ b/src/sampletones_player/clock/step.py
@@ -0,0 +1,31 @@
+from pydantic import BaseModel, ConfigDict, Field
+
+from sampletones_player.specification.clock import (
+ FIXED_POINT_BITS,
+ MAX_STEP_FRACTION,
+ MAX_STEP_WHOLE,
+)
+
+
+class FixedPointStep(BaseModel):
+ """The step the driver adds to its accumulator on every play call.
+
+ The 6502 has no fractional arithmetic, so the step reaches the console as a whole byte and a
+ 16-bit fraction, and the song header carries the two fields as they are written here. Adding
+ them into a 24-bit accumulator and reading the whole ticks off the top is what lets a stream
+ of any rate advance by a fractional amount per call.
+
+ Attributes:
+ whole: The whole ticks every call advances by.
+ fraction: The remainder, in 1/65536ths of a tick.
+ """
+
+ model_config = ConfigDict(extra="forbid", frozen=True)
+
+ whole: int = Field(..., ge=0, le=MAX_STEP_WHOLE)
+ fraction: int = Field(..., ge=0, le=MAX_STEP_FRACTION)
+
+ @property
+ def value(self) -> int:
+ """The step as one number, in the 1/65536ths of a tick the accumulator counts in."""
+ return (self.whole << FIXED_POINT_BITS) | self.fraction
diff --git a/src/sampletones_player/compression/__init__.py b/src/sampletones_player/compression/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/src/sampletones_player/compression/admit.py b/src/sampletones_player/compression/admit.py
new file mode 100644
index 000000000..9cca4fd97
--- /dev/null
+++ b/src/sampletones_player/compression/admit.py
@@ -0,0 +1,159 @@
+from typing import Final, List, NamedTuple, Sequence, Set, Tuple
+
+from sampletones_player.compression.dictionary.phrase import Phrase, phrase_entry_size
+from sampletones_player.compression.dictionary.table import PhraseTable, phrase_table
+from sampletones_player.compression.matches.cache import MIN_PHRASE_TICKS, MatchCache
+from sampletones_player.compression.matches.shift import NO_SHIFT, asked_shift
+from sampletones_player.compression.options import CodecOptions
+from sampletones_player.compression.parse.result import Parse
+from sampletones_player.compression.tokens.sizes import phrase_size
+from sampletones_player.specification.compression import MAX_PHRASE_IDS, PHRASE_ID_ESCAPE
+from sampletones_shared.logger import logger
+
+CROWDED_PHRASE_ID: Final[int] = PHRASE_ID_ESCAPE
+UNSHIFTED_TOKEN_TRANSPOSE: Final[int] = 0
+SHIFTED_TOKEN_TRANSPOSE: Final[int] = 1
+
+
+class _Weighed(NamedTuple):
+ payment: int
+ order: int
+ phrase: Phrase
+
+
+class _Plays(NamedTuple):
+ """What one plane pays a phrase, and how many tokens name it there."""
+
+ paid: int
+ tokens: int
+
+
+def _distinct(seeds: Sequence[Phrase]) -> Tuple[Phrase, ...]:
+ kept: List[Phrase] = []
+ seen: Set[bytes] = set()
+ for seed in seeds:
+ if seed.body not in seen:
+ seen.add(seed.body)
+ kept.append(seed)
+
+ return tuple(kept)
+
+
+def _plays(
+ cache: MatchCache,
+ plane: int,
+ phrase: Phrase,
+ baseline: Parse,
+ *,
+ transposition: bool,
+) -> _Plays:
+ """What one plane pays a phrase, and how many tokens it names it in."""
+ index = cache.index(plane)
+ ticks = cache.reading(plane, phrase).ticks
+ costs = baseline.costs
+ origin = phrase.body[0]
+ paid = 0
+ played = 0
+ position = 0
+ while position < index.ticks:
+ reach = ticks[position]
+ shifted = asked_shift(index.plane[position], origin) != NO_SHIFT
+ if reach >= MIN_PHRASE_TICKS and (transposition or not shifted):
+ paid += costs[position + reach] - costs[position]
+ played += 1
+ position += reach
+ else:
+ position += 1
+
+ return _Plays(paid=paid, tokens=played)
+
+
+def _payment(
+ cache: MatchCache,
+ phrase: Phrase,
+ baseline: Sequence[Parse],
+ options: CodecOptions,
+) -> int:
+ """The bytes a phrase spares the song, its own entry and the tokens naming it taken off.
+
+ A table crowded past its ids names most of its phrases through the escape byte, so every
+ seed is weighed at what one of those tokens costs, which is the price they all compete at.
+ """
+ paid = 0
+ played = 0
+ for plane in range(len(cache.indices)):
+ plays = _plays(
+ cache,
+ plane,
+ phrase,
+ baseline[plane],
+ transposition=options.transposition,
+ )
+ paid += plays.paid
+ played += plays.tokens
+
+ if played == 0:
+ return 0
+
+ stated = phrase_size(CROWDED_PHRASE_ID, UNSHIFTED_TOKEN_TRANSPOSE)
+ shifted = phrase_size(CROWDED_PHRASE_ID, SHIFTED_TOKEN_TRANSPOSE)
+ spent = stated + shifted * (played - 1) + phrase_entry_size(phrase.length)
+ return paid - spent
+
+
+def _best_paying(
+ cache: MatchCache,
+ seeds: Sequence[Phrase],
+ baseline: Sequence[Parse],
+ options: CodecOptions,
+) -> Tuple[Phrase, ...]:
+ weighed = [
+ _Weighed(
+ payment=_payment(cache, seed, baseline, options),
+ order=order,
+ phrase=seed,
+ )
+ for order, seed in enumerate(seeds)
+ ]
+ weighed.sort(key=lambda entry: (-entry.payment, entry.order))
+ return tuple(entry.phrase for entry in weighed[:MAX_PHRASE_IDS])
+
+
+def admit_seeds(
+ cache: MatchCache,
+ seeds: Sequence[Phrase],
+ baseline: Sequence[Parse],
+ options: CodecOptions,
+) -> PhraseTable:
+ """Seeds the dictionary with the phrases a project's instruments offer.
+
+ A token names one of a fixed number of phrases, and a project of many samples offers more
+ shapes than that. The ones kept are the ones sparing the streams most, measured against a
+ reading of the song that names no phrase at all, so a slot goes to the shape a song leans on
+ rather than to whichever instrument the table happens to list first.
+
+ Args:
+ cache: The planes the song covers, alongside what each phrase plays against them.
+ seeds: The phrases the song's instruments offer, in instrument-table order.
+ baseline: The reading each plane takes when its tokens name no phrase.
+ options: Which of the codec's layers the encoding is built from.
+
+ Returns:
+ PhraseTable: The seeds the dictionary takes.
+ """
+ offered = _distinct(seeds)
+ if len(offered) <= MAX_PHRASE_IDS:
+ return phrase_table(offered)
+
+ logger.warning(
+ f"the song's instruments offer {len(offered)} phrases and a dictionary holds "
+ f"{MAX_PHRASE_IDS}, so the {MAX_PHRASE_IDS} sparing the streams most are kept"
+ )
+ return phrase_table(
+ _best_paying(
+ cache,
+ offered,
+ baseline,
+ options,
+ )
+ )
diff --git a/src/sampletones_player/compression/compressed.py b/src/sampletones_player/compression/compressed.py
new file mode 100644
index 000000000..adecebf3f
--- /dev/null
+++ b/src/sampletones_player/compression/compressed.py
@@ -0,0 +1,52 @@
+from __future__ import annotations
+
+from typing import Tuple
+
+from pydantic import BaseModel, ConfigDict, model_validator
+
+from sampletones_player.compression.dictionary.table import PhraseTable
+from sampletones_player.compression.entries import stream_entry
+from sampletones_player.compression.planes.order import PlaneOrder
+
+
+class CompressedPlanes(BaseModel):
+ """A song's planes as the driver reads them: one dictionary and a token stream per plane.
+
+ Attributes:
+ phrases: The dictionary every stream's tokens name.
+ streams: The tokens each plane is written as, in the order the song block writes them.
+ ticks: The ticks the song lasts, which is where each stream stops being read.
+ """
+
+ model_config = ConfigDict(extra="forbid", frozen=True)
+
+ phrases: PhraseTable
+ streams: PlaneOrder
+ ticks: int
+
+ @model_validator(mode="after")
+ def _validate_the_song_lasts(self) -> CompressedPlanes:
+ if self.ticks < 1:
+ raise ValueError(f"a song lasts at least one tick, and this one lasts {self.ticks}")
+
+ return self
+
+ @property
+ def size(self) -> int:
+ """The bytes the dictionary and every plane's stream take together."""
+ return self.phrases.size + sum(len(stream) for stream in self.streams)
+
+ def entries(self, tick: int) -> Tuple[int, ...]:
+ """The byte each stream is re-entered at, for a song returning to ``tick``.
+
+ Args:
+ tick: The tick the song returns to.
+
+ Returns:
+ Tuple[int, ...]: One byte offset per plane, each counted from its own stream's start,
+ in the order the song block writes them.
+
+ Raises:
+ ValueError: If a stream spans ``tick`` rather than starting a token there.
+ """
+ return tuple(stream_entry(stream, tick) for stream in self.streams)
diff --git a/src/sampletones_player/compression/decode.py b/src/sampletones_player/compression/decode.py
new file mode 100644
index 000000000..3e7bd79d9
--- /dev/null
+++ b/src/sampletones_player/compression/decode.py
@@ -0,0 +1,110 @@
+from typing import Tuple
+
+from sampletones_player.compression.compressed import CompressedPlanes
+from sampletones_player.compression.dictionary.table import PhraseTable
+from sampletones_player.compression.planes.order import PlaneOrder
+from sampletones_player.compression.planes.song import SongPlanes
+from sampletones_player.specification.binary import BYTE_VALUES
+from sampletones_player.specification.compression import (
+ INITIAL_PLANE_VALUE,
+ PHRASE_ID_ESCAPE,
+ TOKEN_OPERAND_MASK,
+ TOKEN_TAG_MASK,
+ TokenTag,
+)
+
+
+def _phrase_values(
+ operand: int,
+ data: bytes,
+ position: int,
+ table: PhraseTable,
+ *,
+ transposed: bool,
+) -> Tuple[bytes, int]:
+ phrase_id = operand
+ if operand == PHRASE_ID_ESCAPE:
+ phrase_id = data[position]
+ position += 1
+
+ ticks = data[position] + 1
+ position += 1
+ transpose = 0
+ if transposed:
+ transpose = data[position]
+ position += 1
+
+ body = table[phrase_id].body
+ last = len(body) - 1
+ played = bytes((body[min(offset, last)] + transpose) % BYTE_VALUES for offset in range(ticks))
+ return played, position
+
+
+def decode_plane(data: bytes, table: PhraseTable, ticks: int) -> bytes:
+ """Plays a plane's token stream back into the values it writes, tick by tick.
+
+ This is the reading the driver performs, stated where it is testable: every encoding is held
+ against it, so what the console plays and what the encoder meant are the same values.
+
+ Args:
+ data: The plane's token stream.
+ table: The dictionary the tokens name.
+ ticks: The ticks the song lasts.
+
+ Returns:
+ bytes: The values the plane writes, one per tick.
+ """
+ values = bytearray()
+ current = INITIAL_PLANE_VALUE
+ position = 0
+ while len(values) < ticks:
+ opcode = data[position]
+ position += 1
+ operand = opcode & TOKEN_OPERAND_MASK
+ match TokenTag(opcode & TOKEN_TAG_MASK):
+ case TokenTag.HOLD:
+ played = bytes([current]) * (operand + 1)
+ case TokenTag.LITERAL:
+ played = data[position : position + operand + 1]
+ position += operand + 1
+ case TokenTag.PHRASE:
+ played, position = _phrase_values(
+ operand,
+ data,
+ position,
+ table,
+ transposed=False,
+ )
+ case TokenTag.TRANSPOSED_PHRASE:
+ played, position = _phrase_values(
+ operand,
+ data,
+ position,
+ table,
+ transposed=True,
+ )
+
+ current = played[-1]
+ values.extend(played)
+
+ return bytes(values[:ticks])
+
+
+def decode_planes(compressed: CompressedPlanes) -> SongPlanes:
+ """Plays a song's token streams back into the planes they were written from.
+
+ Args:
+ compressed: The dictionary, the streams and the ticks the song lasts.
+
+ Returns:
+ SongPlanes: The planes under the channel each belongs to.
+ """
+ played = PlaneOrder.across(
+ decode_plane(
+ stream,
+ compressed.phrases,
+ compressed.ticks,
+ )
+ for stream in compressed.streams
+ )
+ return SongPlanes.from_order(played)
diff --git a/src/sampletones_player/compression/dictionary/__init__.py b/src/sampletones_player/compression/dictionary/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/src/sampletones_player/compression/dictionary/phrase.py b/src/sampletones_player/compression/dictionary/phrase.py
new file mode 100644
index 000000000..ea8fabe6d
--- /dev/null
+++ b/src/sampletones_player/compression/dictionary/phrase.py
@@ -0,0 +1,68 @@
+from __future__ import annotations
+
+from functools import cached_property
+
+from pydantic import BaseModel, ConfigDict, model_validator
+
+from sampletones_player.specification.binary import BYTE_VALUES
+from sampletones_player.specification.compression import (
+ MAX_PHRASE_LENGTH,
+ PHRASE_LENGTH_SIZE,
+ PHRASE_TABLE_ENTRY_SIZE,
+)
+
+
+def phrase_entry_size(length: int) -> int:
+ """The bytes a phrase of ``length`` values takes in the song block, its table entry included.
+
+ The search weighs a candidate against what its entry would cost before any phrase is built
+ from it, so the cost is stated over the length alone.
+
+ Args:
+ length: The values the phrase plays, one per tick.
+
+ Returns:
+ int: The bytes the phrase and its table entry take together.
+ """
+ return PHRASE_TABLE_ENTRY_SIZE + PHRASE_LENGTH_SIZE + length
+
+
+class Phrase(BaseModel):
+ """One entry of the dictionary: the values a plane plays when a token names it.
+
+ A phrase is stored at the pitch it was found at, and a token states the shift it is played
+ at, so a note's shape is written once and every pitch it sounds at names that one entry.
+
+ Attributes:
+ body: The values the phrase plays, one per tick.
+ """
+
+ model_config = ConfigDict(extra="forbid", frozen=True)
+
+ body: bytes
+
+ @model_validator(mode="after")
+ def _validate_the_body_fits_a_table_entry(self) -> Phrase:
+ if not 1 <= len(self.body) <= MAX_PHRASE_LENGTH:
+ raise ValueError(f"a phrase runs from 1 to {MAX_PHRASE_LENGTH} values, and this one runs {len(self.body)}")
+
+ return self
+
+ @property
+ def length(self) -> int:
+ """The ticks the phrase's own values cover."""
+ return len(self.body)
+
+ @cached_property
+ def differences(self) -> bytes:
+ """The step from each value to the next, which is the shape a shift leaves alone.
+
+ A phrase is read for its shape every time a table is indexed, and a table is indexed once
+ per parse, so the shape is derived once and the phrase carries it thereafter.
+ """
+ return bytes((following - value) % BYTE_VALUES for value, following in zip(self.body, self.body[1:]))
+
+ @property
+ def size(self) -> int:
+ """The bytes the phrase takes in the song block, its table entry included."""
+ return phrase_entry_size(len(self.body))
diff --git a/src/sampletones_player/compression/dictionary/prune.py b/src/sampletones_player/compression/dictionary/prune.py
new file mode 100644
index 000000000..2b9e60254
--- /dev/null
+++ b/src/sampletones_player/compression/dictionary/prune.py
@@ -0,0 +1,44 @@
+from typing import List, Mapping, NamedTuple
+
+from sampletones_player.compression.dictionary.table import PhraseTable
+
+
+class _KeptPhrase(NamedTuple):
+ references: int
+ phrase_id: int
+
+
+def _pays_for_itself(
+ table: PhraseTable,
+ references: Mapping[int, int],
+ savings: Mapping[int, int],
+) -> List[_KeptPhrase]:
+ return [
+ _KeptPhrase(references=references[phrase_id], phrase_id=phrase_id)
+ for phrase_id in range(len(table))
+ if references[phrase_id] > 0 and savings[phrase_id] > table[phrase_id].size
+ ]
+
+
+def prune(
+ table: PhraseTable,
+ references: Mapping[int, int],
+ savings: Mapping[int, int],
+) -> PhraseTable:
+ """Rebuilds a table around the phrases that pay for themselves, most named first.
+
+ A phrase earns its place by sparing the streams more bytes than its own entry takes, and the
+ ids it competes for are worth a byte apiece: the cheap ones ride inside an opcode, so the
+ phrases named most often take them and the tokens naming those shed a byte each.
+
+ Args:
+ table: The table the tokens were parsed against.
+ references: How many tokens name each phrase id.
+ savings: The bytes each phrase's tokens spare the streams.
+
+ Returns:
+ PhraseTable: The phrases worth keeping, ordered by how often they are named.
+ """
+ kept = _pays_for_itself(table, references, savings)
+ kept.sort(key=lambda entry: (-entry.references, entry.phrase_id))
+ return PhraseTable(phrases=tuple(table[entry.phrase_id] for entry in kept))
diff --git a/src/sampletones_player/compression/dictionary/table.py b/src/sampletones_player/compression/dictionary/table.py
new file mode 100644
index 000000000..5b13edd33
--- /dev/null
+++ b/src/sampletones_player/compression/dictionary/table.py
@@ -0,0 +1,65 @@
+from __future__ import annotations
+
+from typing import Iterable, Set, Tuple
+
+from pydantic import BaseModel, ConfigDict, model_validator
+
+from sampletones_player.compression.dictionary.phrase import Phrase
+from sampletones_player.specification.compression import (
+ MAX_PHRASE_IDS,
+ PHRASE_TABLE_COUNT_SIZE,
+)
+
+
+class PhraseTable(BaseModel):
+ """The dictionary a song's tokens name, in the order the song block writes it.
+
+ A phrase's position is its id, and the cheap ids ride inside a token's opcode, so the order
+ is part of the encoding: the phrases a song leans on hardest take the ids that cost least.
+
+ Attributes:
+ phrases: The phrases, in id order.
+ """
+
+ model_config = ConfigDict(extra="forbid", frozen=True)
+
+ phrases: Tuple[Phrase, ...]
+
+ @model_validator(mode="after")
+ def _validate_the_table_fits_the_ids_a_token_reaches(self) -> PhraseTable:
+ if len(self.phrases) > MAX_PHRASE_IDS:
+ raise ValueError(f"a token names one of {MAX_PHRASE_IDS} phrases, and the table holds {len(self.phrases)}")
+
+ return self
+
+ def __len__(self) -> int:
+ return len(self.phrases)
+
+ def __getitem__(self, phrase_id: int) -> Phrase:
+ return self.phrases[phrase_id]
+
+ @property
+ def size(self) -> int:
+ """The bytes the whole dictionary takes in the song block."""
+ return PHRASE_TABLE_COUNT_SIZE + sum(phrase.size for phrase in self.phrases)
+
+
+def phrase_table(phrases: Iterable[Phrase]) -> PhraseTable:
+ """Collects phrases into a table, each shape kept once and the ids capped at what fits.
+
+ Args:
+ phrases: The phrases to hold, in the order they are preferred.
+
+ Returns:
+ PhraseTable: The table, holding as many of them as a token can name.
+ """
+ collected: Tuple[Phrase, ...] = ()
+ seen: Set[bytes] = set()
+ for phrase in phrases:
+ if phrase.body in seen or len(collected) == MAX_PHRASE_IDS:
+ continue
+
+ seen.add(phrase.body)
+ collected += (phrase,)
+
+ return PhraseTable(phrases=collected)
diff --git a/src/sampletones_player/compression/encode.py b/src/sampletones_player/compression/encode.py
new file mode 100644
index 000000000..1cf52ccfe
--- /dev/null
+++ b/src/sampletones_player/compression/encode.py
@@ -0,0 +1,189 @@
+from dataclasses import replace
+from typing import Dict, Final, FrozenSet, Iterable, Sequence, Tuple
+
+from sampletones_player.compression.admit import admit_seeds
+from sampletones_player.compression.compressed import CompressedPlanes
+from sampletones_player.compression.dictionary.phrase import Phrase
+from sampletones_player.compression.dictionary.prune import prune
+from sampletones_player.compression.dictionary.table import PhraseTable, phrase_table
+from sampletones_player.compression.matches.cache import MatchCache
+from sampletones_player.compression.matches.index import PlaneIndex
+from sampletones_player.compression.options import CodecOptions
+from sampletones_player.compression.parse.result import Parse
+from sampletones_player.compression.parse.song import parse_planes
+from sampletones_player.compression.planes.order import PlaneOrder
+from sampletones_player.compression.planes.song import SongPlanes
+from sampletones_player.compression.progress.monitor import CodecMonitor
+from sampletones_player.compression.progress.report import (
+ CodecReporter,
+)
+from sampletones_player.compression.search import search_phrases
+from sampletones_player.compression.tokens.hold import HoldToken
+from sampletones_player.compression.tokens.literal import LiteralToken
+from sampletones_player.compression.tokens.phrase import PhraseToken
+from sampletones_player.compression.tokens.types import TokenUnion
+from sampletones_player.specification.compression import PHRASE_ID_ESCAPE, TokenTag
+from sampletones_shared.utils.progress import silent_reporter
+
+STREAM_START: Final[int] = 0
+SETTLING_ROUNDS: Final[int] = 3
+
+
+def emit(tokens: Sequence[TokenUnion]) -> bytes:
+ """Writes a plane's tokens out as the bytes the driver reads them from.
+
+ Args:
+ tokens: The tokens the plane is written as, in the order they are read.
+
+ Returns:
+ bytes: The plane's token stream.
+ """
+ stream = bytearray()
+ for token in tokens:
+ match token:
+ case HoldToken():
+ stream.append(TokenTag.HOLD | (token.ticks - 1))
+ case LiteralToken():
+ stream.append(TokenTag.LITERAL | (len(token.values) - 1))
+ stream.extend(token.values)
+ case PhraseToken():
+ tag = TokenTag.TRANSPOSED_PHRASE if token.transpose else TokenTag.PHRASE
+ named = min(token.phrase_id, PHRASE_ID_ESCAPE)
+ stream.append(tag | named)
+ if named == PHRASE_ID_ESCAPE:
+ stream.append(token.phrase_id)
+
+ stream.append(token.ticks - 1)
+ if token.transpose:
+ stream.append(token.transpose)
+
+ return bytes(stream)
+
+
+def _references(parses: Iterable[Parse], phrases: int) -> Dict[int, int]:
+ references = {phrase_id: 0 for phrase_id in range(phrases)}
+ for parse in parses:
+ for token in parse.tokens:
+ if isinstance(token, PhraseToken):
+ references[token.phrase_id] += 1
+
+ return references
+
+
+def _savings(
+ parses: Sequence[Parse],
+ baseline: Sequence[Parse],
+ phrases: int,
+) -> Dict[int, int]:
+ savings = {phrase_id: 0 for phrase_id in range(phrases)}
+ for parse, plain in zip(parses, baseline):
+ position = 0
+ for token in parse.tokens:
+ if isinstance(token, PhraseToken):
+ spared = plain.costs[position + token.ticks] - plain.costs[position]
+ savings[token.phrase_id] += spared - token.size
+
+ position += token.ticks
+
+ return savings
+
+
+def _settle(
+ cache: MatchCache,
+ table: PhraseTable,
+ options: CodecOptions,
+ boundaries: FrozenSet[int],
+ monitor: CodecMonitor,
+ baseline: Sequence[Parse],
+) -> Tuple[PhraseTable, Tuple[Parse, ...]]:
+ parses = parse_planes(cache, table, options, boundaries, monitor)
+ for _ in range(SETTLING_ROUNDS):
+ pruned = prune(
+ table,
+ _references(parses, len(table)),
+ _savings(parses, baseline, len(table)),
+ )
+ if pruned.phrases == table.phrases:
+ break
+
+ table = pruned
+ parses = parse_planes(cache, table, options, boundaries, monitor)
+ monitor.reached(len(table), table.size + sum(parse.size for parse in parses))
+
+ return table, parses
+
+
+def encode_planes(
+ planes: SongPlanes,
+ seeds: Sequence[Phrase],
+ *,
+ options: CodecOptions,
+ boundaries: FrozenSet[int],
+ report: CodecReporter = silent_reporter,
+) -> CompressedPlanes:
+ """Compresses a song's planes into the dictionary and streams the driver reads.
+
+ Every layer is weighed against one reading of the song naming no phrase at all: the seeds a
+ dictionary crowded past its ids keeps, and the bytes each phrase spares once the table
+ settles. The instruments seed the dictionary, the search fills what they leave behind, and
+ the table then settles — phrases the parse names keep their place in the order they are
+ leaned on, and the parse runs again over the ids that frees, which is what puts the busiest
+ phrases inside the opcodes that name them.
+
+ Args:
+ planes: The planes under the channel each belongs to.
+ seeds: The phrases the song's instruments offer.
+ options: Which of the codec's layers the encoding is built from.
+ boundaries: The ticks a token starts on, beyond the first tick of the song.
+ report: Hears what the run holds each time it looks up, and answers whether it goes on.
+
+ Returns:
+ CompressedPlanes: The dictionary, every plane's token stream and the ticks the song lasts.
+
+ Raises:
+ OperationCanceled: If ``report`` withdraws the run.
+ """
+ cache = MatchCache(PlaneIndex.from_plane(plane) for plane in planes.planes)
+ monitor = CodecMonitor(report)
+ entries = boundaries | {STREAM_START}
+ baseline = parse_planes(
+ cache,
+ phrase_table(()),
+ replace(options, phrases=False),
+ entries,
+ monitor,
+ )
+ table = (
+ admit_seeds(
+ cache,
+ seeds,
+ baseline,
+ options,
+ )
+ if options.phrases
+ else phrase_table(())
+ )
+ if options.phrases and options.search:
+ table = search_phrases(
+ cache,
+ table,
+ options,
+ entries,
+ monitor,
+ )
+
+ table, parses = _settle(
+ cache,
+ table,
+ options,
+ entries,
+ monitor,
+ baseline,
+ )
+ compressed = CompressedPlanes(
+ phrases=table,
+ streams=PlaneOrder.across(emit(parse.tokens) for parse in parses),
+ ticks=planes.ticks,
+ )
+ monitor.reached(len(table), compressed.size)
+ return compressed
diff --git a/src/sampletones_player/compression/entries.py b/src/sampletones_player/compression/entries.py
new file mode 100644
index 000000000..84dac3509
--- /dev/null
+++ b/src/sampletones_player/compression/entries.py
@@ -0,0 +1,31 @@
+from sampletones_player.compression.tokens.span import token_span
+
+
+def stream_entry(stream: bytes, tick: int) -> int:
+ """The byte of ``stream`` the token covering ``tick`` begins at.
+
+ A song that repeats re-enters its streams partway through, and what the driver needs to
+ resume there is where each plane's next opcode lies. The encoder holds a token boundary at
+ the tick a song returns to, so the walk lands on it exactly.
+
+ Args:
+ stream: The plane's token stream.
+ tick: The tick the stream is re-entered at.
+
+ Returns:
+ int: The byte the token covering ``tick`` begins at, counted from the stream's own start.
+
+ Raises:
+ ValueError: If the stream spans ``tick`` rather than starting a token there.
+ """
+ position = 0
+ reached = 0
+ while reached < tick:
+ span = token_span(stream, position)
+ reached += span.ticks
+ position += span.size
+
+ if reached != tick:
+ raise ValueError(f"the stream spans tick {tick} rather than starting a token there")
+
+ return position
diff --git a/src/sampletones_player/compression/matches/__init__.py b/src/sampletones_player/compression/matches/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/src/sampletones_player/compression/matches/cache.py b/src/sampletones_player/compression/matches/cache.py
new file mode 100644
index 000000000..e844a6d3b
--- /dev/null
+++ b/src/sampletones_player/compression/matches/cache.py
@@ -0,0 +1,117 @@
+from array import array
+from typing import Dict, Final, Iterable, List, Optional, Tuple
+
+from sampletones_player.compression.dictionary.phrase import Phrase
+from sampletones_player.compression.matches.index import PlaneIndex
+from sampletones_player.compression.matches.played import played_ticks
+from sampletones_player.compression.matches.reading import PhraseReading
+from sampletones_player.compression.matches.shift import NO_SHIFT, asked_shift, translation
+from sampletones_player.specification.compression import MAX_PHRASE_TICKS
+
+KEY_LENGTH: Final[int] = 2
+TICKS_TYPECODE: Final[str] = "H"
+TICKS_ENTRY_SIZE: Final[int] = 2
+NO_MATCH: Final[int] = 0
+MIN_PHRASE_TICKS: Final[int] = 2
+
+
+class MatchCache:
+ """What each phrase plays against each plane, measured once for a whole encoding.
+
+ A phrase's match at a tick follows from the plane and the phrase alone, so it holds for every
+ parse the encoder runs: a search round adds one phrase and measures that one, while every
+ phrase already in the table answers from the reading taken when it arrived. This is what
+ turns the cost of encoding from the parses times the dictionary into the dictionary alone.
+
+ Each reading is taken against the most ticks a token could ever cover from that position, and
+ a parse reading it under a shorter reach takes the smaller of the two — the same answer
+ measuring again would give, since a phrase matches for as long as the plane agrees with it
+ and a shorter reach only cuts that agreement short.
+
+ Positions are offered to a phrase by its first steps, the way the shortlist offers them, so a
+ reading covers the ticks the phrase could begin at rather than every tick of the plane.
+ """
+
+ def __init__(self, indices: Iterable[PlaneIndex]) -> None:
+ self._indices: Tuple[PlaneIndex, ...] = tuple(indices)
+ self._offers: List[Optional[Dict[bytes, Tuple[int, ...]]]] = [None] * len(self._indices)
+ self._readings: Dict[Tuple[int, bytes], PhraseReading] = {}
+
+ @property
+ def indices(self) -> Tuple[PlaneIndex, ...]:
+ """The planes the encoding covers, in song-block order."""
+ return self._indices
+
+ def index(self, plane: int) -> PlaneIndex:
+ """The plane at ``plane`` and the readings of it matching is decided against.
+
+ Args:
+ plane: The plane's position in song-block order.
+
+ Returns:
+ PlaneIndex: The plane, its steps and its runs.
+ """
+ return self._indices[plane]
+
+ def reading(self, plane: int, phrase: Phrase) -> PhraseReading:
+ """What ``phrase`` plays against ``plane``, measured on first ask and kept thereafter.
+
+ Args:
+ plane: The plane's position in song-block order.
+ phrase: The phrase the plane is read against.
+
+ Returns:
+ PhraseReading: The ticks played from each position, and whether the plane plays the
+ phrase at all.
+ """
+ entry = (plane, phrase.body)
+ measured = self._readings.get(entry)
+ if measured is None:
+ measured = self._measure(plane, phrase)
+ self._readings[entry] = measured
+
+ return measured
+
+ def _measure(self, plane: int, phrase: Phrase) -> PhraseReading:
+ index = self._indices[plane]
+ body = phrase.body
+ origin = body[0]
+ measured = array(TICKS_TYPECODE, bytes(TICKS_ENTRY_SIZE * index.ticks))
+ shifted = False
+ unshifted = False
+ for position in self._offered(plane, phrase):
+ transpose = asked_shift(index.plane[position], origin)
+ ticks = played_ticks(
+ index,
+ position,
+ body.translate(translation(transpose)),
+ min(MAX_PHRASE_TICKS, index.ticks - position),
+ )
+ measured[position] = ticks
+ if ticks >= MIN_PHRASE_TICKS:
+ shifted = True
+ unshifted = unshifted or transpose == NO_SHIFT
+
+ return PhraseReading(ticks=measured, shifted=shifted, unshifted=unshifted)
+
+ def _offered(self, plane: int, phrase: Phrase) -> Iterable[int]:
+ differences = phrase.differences
+ if len(differences) < KEY_LENGTH:
+ return range(self._indices[plane].ticks)
+
+ return self._offers_by_key(plane).get(differences[:KEY_LENGTH], ())
+
+ def _offers_by_key(self, plane: int) -> Dict[bytes, Tuple[int, ...]]:
+ cached = self._offers[plane]
+ if cached is not None:
+ return cached
+
+ index = self._indices[plane]
+ differences = index.differences
+ gathered: Dict[bytes, List[int]] = {}
+ for position in range(index.ticks):
+ gathered.setdefault(differences[position : position + KEY_LENGTH], []).append(position)
+
+ offers = {key: tuple(positions) for key, positions in gathered.items()}
+ self._offers[plane] = offers
+ return offers
diff --git a/src/sampletones_player/compression/matches/index.py b/src/sampletones_player/compression/matches/index.py
new file mode 100644
index 000000000..cbe5a4687
--- /dev/null
+++ b/src/sampletones_player/compression/matches/index.py
@@ -0,0 +1,54 @@
+from __future__ import annotations
+
+from dataclasses import dataclass
+from itertools import pairwise
+from typing import List, Tuple
+
+from sampletones_player.specification.binary import BYTE_VALUES
+
+
+@dataclass(frozen=True)
+class PlaneIndex:
+ """A plane alongside the two readings of it every match is decided by.
+
+ A phrase is stored at one pitch and played at any, so what identifies it is the step from
+ each value to the next rather than the values themselves. The runs answer the other half:
+ how far a value carries once a phrase has played its last, which is how a token states a
+ note that outlasts its envelope.
+
+ Attributes:
+ plane: The values the plane plays, one per tick.
+ differences: The step from each value to the next.
+ runs: How many ticks the value at each position holds for.
+ """
+
+ plane: bytes
+ differences: bytes
+ runs: Tuple[int, ...]
+
+ @classmethod
+ def from_plane(cls, plane: bytes) -> PlaneIndex:
+ """Reads a plane into the form matching is decided against.
+
+ Args:
+ plane: The values the plane plays, one per tick.
+
+ Returns:
+ PlaneIndex: The plane, its steps and its runs.
+ """
+ differences = bytes((following - value) % BYTE_VALUES for value, following in pairwise(plane))
+ runs: List[int] = [1] * len(plane)
+ for position in range(len(plane) - 2, -1, -1):
+ if plane[position] == plane[position + 1]:
+ runs[position] = runs[position + 1] + 1
+
+ return cls(
+ plane=plane,
+ differences=differences,
+ runs=tuple(runs),
+ )
+
+ @property
+ def ticks(self) -> int:
+ """The ticks the plane covers."""
+ return len(self.plane)
diff --git a/src/sampletones_player/compression/matches/match.py b/src/sampletones_player/compression/matches/match.py
new file mode 100644
index 000000000..ebfd4c4c9
--- /dev/null
+++ b/src/sampletones_player/compression/matches/match.py
@@ -0,0 +1,15 @@
+from typing import NamedTuple
+
+
+class PhraseMatch(NamedTuple):
+ """A phrase the plane plays from a tick, and the terms it plays it on.
+
+ Attributes:
+ phrase_id: Position the phrase takes in the table.
+ ticks: The ticks the plane plays of it, its final value held past its end.
+ transpose: The shift every byte of it is played at.
+ """
+
+ phrase_id: int
+ ticks: int
+ transpose: int
diff --git a/src/sampletones_player/compression/matches/matcher.py b/src/sampletones_player/compression/matches/matcher.py
new file mode 100644
index 000000000..89e772271
--- /dev/null
+++ b/src/sampletones_player/compression/matches/matcher.py
@@ -0,0 +1,88 @@
+from itertools import chain
+from typing import Dict, Iterator, List, Sequence, Tuple
+
+from sampletones_player.compression.dictionary.table import PhraseTable
+from sampletones_player.compression.matches.cache import KEY_LENGTH, MIN_PHRASE_TICKS, MatchCache
+from sampletones_player.compression.matches.index import PlaneIndex
+from sampletones_player.compression.matches.match import PhraseMatch
+from sampletones_player.specification.binary import BYTE_VALUES
+
+
+class PhraseMatcher:
+ """Answers which phrases one plane plays at a tick, and for how many ticks.
+
+ Phrases are held under the first steps of their shape, so a position offers a handful of
+ candidates rather than the whole dictionary. A candidate plays at the shift its first value
+ asks for, which is the one shift that can possibly match there, and the ticks it plays for
+ are read from the cache the whole encoding shares.
+
+ A phrase is offered where the plane plays enough of it for those steps to tell it apart,
+ which is a tick longer than the shortlist's key. A note cut shorter than that is spelled out,
+ where a phrase token and a literal cost the same anyway.
+
+ A matcher answers for the one plane it was built against, so the plane and the dictionary it
+ is read under arrive together and stay together.
+ """
+
+ def __init__(
+ self,
+ table: PhraseTable,
+ plane: int,
+ cache: MatchCache,
+ ) -> None:
+ self._index: PlaneIndex = cache.index(plane)
+ self._origins: Tuple[int, ...] = tuple(phrase.body[0] for phrase in table.phrases)
+ self._played: Tuple[Sequence[int], ...] = tuple(cache.reading(plane, phrase).ticks for phrase in table.phrases)
+ keyed: Dict[bytes, List[int]] = {}
+ short: List[int] = []
+ for phrase_id, phrase in enumerate(table.phrases):
+ differences = phrase.differences
+ if len(differences) < KEY_LENGTH:
+ short.append(phrase_id)
+ continue
+
+ keyed.setdefault(differences[:KEY_LENGTH], []).append(phrase_id)
+
+ self._keyed: Dict[bytes, Tuple[int, ...]] = {key: tuple(ids) for key, ids in keyed.items()}
+ self._short: Tuple[int, ...] = tuple(short)
+
+ @property
+ def index(self) -> PlaneIndex:
+ """The plane the matcher answers for, and the readings of it matching is decided against."""
+ return self._index
+
+ def matches(
+ self,
+ position: int,
+ limit: int,
+ *,
+ transposition: bool,
+ ) -> Iterator[PhraseMatch]:
+ """Every phrase the plane plays from ``position``, with the ticks and shift it plays at.
+
+ Args:
+ position: The tick the phrase would start at.
+ limit: The most ticks a token may cover from there.
+ transposition: Whether a phrase may play at a shift.
+
+ Yields:
+ PhraseMatch: The phrase, the ticks it covers and the shift it plays at.
+ """
+ if limit < MIN_PHRASE_TICKS:
+ return
+
+ index = self._index
+ origin = index.plane[position]
+ key = index.differences[position : position + KEY_LENGTH]
+ for phrase_id in chain(self._keyed.get(key, ()), self._short):
+ transpose = (origin - self._origins[phrase_id]) % BYTE_VALUES
+ if transpose and not transposition:
+ continue
+
+ ticks = min(self._played[phrase_id][position], limit)
+ if ticks >= MIN_PHRASE_TICKS:
+ yield PhraseMatch(
+ phrase_id=phrase_id,
+ ticks=ticks,
+ transpose=transpose,
+ )
diff --git a/src/sampletones_player/compression/matches/played.py b/src/sampletones_player/compression/matches/played.py
new file mode 100644
index 000000000..b733a2f9d
--- /dev/null
+++ b/src/sampletones_player/compression/matches/played.py
@@ -0,0 +1,85 @@
+from sampletones_player.compression.matches.index import PlaneIndex
+
+
+def _common_prefix(
+ plane: bytes,
+ position: int,
+ expected: bytes,
+ usable: int,
+) -> int:
+ for offset in range(usable):
+ if plane[position + offset] != expected[offset]:
+ return offset
+
+ return usable
+
+
+def _matched_ticks(
+ plane: bytes,
+ position: int,
+ expected: bytes,
+ usable: int,
+) -> int:
+ """The ticks the plane agrees with ``expected`` for, out of the ``usable`` it may play."""
+ if plane.startswith(expected[:usable], position):
+ return usable
+
+ return _common_prefix(
+ plane,
+ position,
+ expected,
+ usable,
+ )
+
+
+def _held_ticks(
+ index: PlaneIndex,
+ position: int,
+ expected: bytes,
+ limit: int,
+) -> int:
+ """The ticks a phrase covers once it has played out, its final value carrying onwards."""
+ end = position + len(expected)
+ if len(expected) == limit or index.plane[end] != expected[-1]:
+ return len(expected)
+
+ return min(limit, len(expected) + index.runs[end])
+
+
+def played_ticks(
+ index: PlaneIndex,
+ position: int,
+ expected: bytes,
+ limit: int,
+) -> int:
+ """The ticks the plane plays of ``expected`` from ``position``.
+
+ A phrase matches for as long as the plane agrees with it, and a phrase the plane plays whole
+ keeps covering ticks for as long as the value it ended on holds — which is the note that
+ outlasts its envelope, stated by the one token.
+
+ Args:
+ index: The plane and the two readings of it matching is decided against.
+ position: The tick the phrase would start at.
+ expected: The values the phrase plays, already at the shift it is played at.
+ limit: The most ticks a token may cover from there.
+
+ Returns:
+ int: The ticks the phrase covers, which is none where the plane parts from it at once.
+ """
+ usable = min(len(expected), limit)
+ played = _matched_ticks(
+ index.plane,
+ position,
+ expected,
+ usable,
+ )
+ if played < usable or usable < len(expected):
+ return played
+
+ return _held_ticks(
+ index,
+ position,
+ expected,
+ limit,
+ )
diff --git a/src/sampletones_player/compression/matches/reading.py b/src/sampletones_player/compression/matches/reading.py
new file mode 100644
index 000000000..cc5263024
--- /dev/null
+++ b/src/sampletones_player/compression/matches/reading.py
@@ -0,0 +1,32 @@
+from dataclasses import dataclass
+from typing import Sequence
+
+
+@dataclass(frozen=True)
+class PhraseReading:
+ """What one phrase plays against one plane, read once for a whole encoding.
+
+ The ticks answer a parse asking what a token from a position would cover. The reach answers
+ the search asking whether a plane needs reading again at all: a phrase the plane never plays
+ leaves that plane's tokens exactly as they were.
+
+ Attributes:
+ ticks: The ticks the phrase plays for from each tick of the plane.
+ shifted: Whether the plane plays the phrase anywhere, at whatever shift it asks for.
+ unshifted: Whether the plane plays the phrase anywhere at the pitch it was stored at.
+ """
+
+ ticks: Sequence[int]
+ shifted: bool
+ unshifted: bool
+
+ def reaches(self, *, transposition: bool) -> bool:
+ """Whether the plane plays the phrase under the layers the encoding is built from.
+
+ Args:
+ transposition: Whether a phrase may play at a shift.
+
+ Returns:
+ bool: Whether the phrase reaches the plane at all.
+ """
+ return self.shifted if transposition else self.unshifted
diff --git a/src/sampletones_player/compression/matches/shift.py b/src/sampletones_player/compression/matches/shift.py
new file mode 100644
index 000000000..26ff801c7
--- /dev/null
+++ b/src/sampletones_player/compression/matches/shift.py
@@ -0,0 +1,42 @@
+from functools import lru_cache
+from typing import Final
+
+from sampletones_player.specification.binary import BYTE_VALUES
+
+NO_SHIFT: Final[int] = 0
+
+
+def asked_shift(value: int, origin: int) -> int:
+ """The shift a plane standing at ``value`` asks a phrase beginning at ``origin`` for.
+
+ A phrase is stored at one pitch and played at any, so what a position asks of it is the step
+ from the phrase's own first value to the one standing there, taken within the byte.
+
+ Args:
+ value: The value the plane holds at the position the phrase is offered.
+ origin: The phrase's own first value.
+
+ Returns:
+ int: The shift the phrase is played at from there.
+ """
+ return (value - origin) % BYTE_VALUES
+
+
+@lru_cache(maxsize=BYTE_VALUES)
+def translation(transpose: int) -> bytes:
+ """The byte table playing a phrase at a shift, every value moved by ``transpose``.
+
+ A shift wraps within the byte, which is the one addition the driver performs and the one the
+ encoder has to agree with. The tables are kept as they are asked for, since a song reaches
+ for the same handful of shifts across every plane.
+
+ Args:
+ transpose: The shift every byte of a phrase is played at.
+
+ Returns:
+ bytes: The translation table the shift is applied through.
+ """
+ return bytes.maketrans(
+ bytes(range(BYTE_VALUES)),
+ bytes((value + transpose) % BYTE_VALUES for value in range(BYTE_VALUES)),
+ )
diff --git a/src/sampletones_player/compression/options.py b/src/sampletones_player/compression/options.py
new file mode 100644
index 000000000..be3e971ad
--- /dev/null
+++ b/src/sampletones_player/compression/options.py
@@ -0,0 +1,32 @@
+from dataclasses import dataclass
+from typing import Final
+
+
+@dataclass(frozen=True)
+class CodecOptions:
+ """Which of the codec's layers an encoding is built from.
+
+ Every layer earns its place on measured ground, so each is switched on its own and a report
+ reads the bytes each one saves. Literals carry any plane on their own, so an encoding with
+ every layer off still describes the song.
+
+ Attributes:
+ holds: Whether a run of one value reaches the stream as a hold.
+ phrases: Whether tokens name phrases from the dictionary.
+ transposition: Whether a phrase is played shifted, one entry serving every pitch a note
+ is played at.
+ search: Whether the encoder looks for phrases beyond the ones the instruments seed.
+ """
+
+ holds: bool
+ phrases: bool
+ transposition: bool
+ search: bool
+
+
+EVERY_LAYER: Final[CodecOptions] = CodecOptions(
+ holds=True,
+ phrases=True,
+ transposition=True,
+ search=True,
+)
diff --git a/src/sampletones_player/compression/parse/__init__.py b/src/sampletones_player/compression/parse/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/src/sampletones_player/compression/parse/boundaries.py b/src/sampletones_player/compression/parse/boundaries.py
new file mode 100644
index 000000000..4b383416e
--- /dev/null
+++ b/src/sampletones_player/compression/parse/boundaries.py
@@ -0,0 +1,49 @@
+from __future__ import annotations
+
+from dataclasses import dataclass
+from typing import FrozenSet, List, Tuple
+
+
+@dataclass(frozen=True)
+class Boundaries:
+ """The ticks a token starts on, read forwards and backwards from every tick of a plane.
+
+ A loop re-enters the stream partway through, so the tick it re-enters at holds a token of
+ its own and nothing spans across it. Knowing the nearest boundary either side of a tick is
+ what keeps that true while the cheapest reading is searched for.
+
+ Attributes:
+ entries: The ticks a token is required to start on.
+ previous: The nearest boundary at or before each tick.
+ following: The nearest boundary after each tick, the plane's end standing in beyond the
+ last of them.
+ """
+
+ entries: FrozenSet[int]
+ previous: Tuple[int, ...]
+ following: Tuple[int, ...]
+
+ @classmethod
+ def across(cls, ticks: int, entries: FrozenSet[int]) -> Boundaries:
+ """Reads the boundaries of a plane into the two lookups a parse asks them by.
+
+ Args:
+ ticks: The ticks the plane covers.
+ entries: The ticks a token is required to start on.
+
+ Returns:
+ Boundaries: The entries and the nearest one either side of every tick.
+ """
+ previous: List[int] = [0] * (ticks + 1)
+ for position in range(1, ticks + 1):
+ previous[position] = position - 1 if position - 1 in entries else previous[position - 1]
+
+ following: List[int] = [ticks] * (ticks + 1)
+ for position in range(ticks - 1, -1, -1):
+ following[position] = position + 1 if position + 1 in entries else following[position + 1]
+
+ return cls(
+ entries=entries,
+ previous=tuple(previous),
+ following=tuple(following),
+ )
diff --git a/src/sampletones_player/compression/parse/literals.py b/src/sampletones_player/compression/parse/literals.py
new file mode 100644
index 000000000..3c1233d9e
--- /dev/null
+++ b/src/sampletones_player/compression/parse/literals.py
@@ -0,0 +1,42 @@
+from collections import deque
+from typing import Deque, Sequence
+
+
+class LiteralWindow:
+ """The tick a literal reaching the tick under consideration is cheapest to start from.
+
+ A literal costs its opcode and its bytes alike whatever its length, so the start worth taking
+ is the one whose own cost, set against how far back it sits, is least — and a start beaten
+ by a later one is beaten for good. Keeping the starts in that order leaves the best of them
+ at the front, which is what lets one pass over a plane price every literal in it.
+ """
+
+ def __init__(self, costs: Sequence[int]) -> None:
+ self._costs = costs
+ self._starts: Deque[int] = deque()
+
+ def cheapest(self, position: int, earliest: int) -> int:
+ """The tick the cheapest literal ending at ``position`` starts on.
+
+ The tick before ``position`` joins the window as a start of its own, the starts it has
+ beaten leave it, and so do the ones that have fallen out of reach.
+
+ Args:
+ position: The tick the literal ends at.
+ earliest: The earliest tick that literal may start on.
+
+ Returns:
+ int: The tick the cheapest literal starts on.
+ """
+ starts = self._starts
+ costs = self._costs
+ start = position - 1
+ key = costs[start] - start
+ while starts and costs[starts[-1]] - starts[-1] >= key:
+ starts.pop()
+
+ starts.append(start)
+ while starts[0] < earliest:
+ starts.popleft()
+
+ return starts[0]
diff --git a/src/sampletones_player/compression/parse/plane.py b/src/sampletones_player/compression/parse/plane.py
new file mode 100644
index 000000000..53ce7146e
--- /dev/null
+++ b/src/sampletones_player/compression/parse/plane.py
@@ -0,0 +1,128 @@
+from typing import FrozenSet
+
+from sampletones_player.compression.matches.index import PlaneIndex
+from sampletones_player.compression.matches.matcher import PhraseMatcher
+from sampletones_player.compression.options import CodecOptions
+from sampletones_player.compression.parse.boundaries import Boundaries
+from sampletones_player.compression.parse.literals import LiteralWindow
+from sampletones_player.compression.parse.result import Parse
+from sampletones_player.compression.parse.shortest import Shortest
+from sampletones_player.compression.tokens.hold import HoldToken
+from sampletones_player.compression.tokens.literal import LiteralToken
+from sampletones_player.compression.tokens.phrase import PhraseToken
+from sampletones_player.compression.tokens.sizes import (
+ hold_size,
+ literal_size,
+ phrase_size,
+)
+from sampletones_player.specification.compression import (
+ MAX_HOLD_TICKS,
+ MAX_LITERAL_BYTES,
+ MAX_PHRASE_TICKS,
+)
+
+
+def _relax_literal(
+ shortest: Shortest,
+ plane: bytes,
+ window: LiteralWindow,
+ position: int,
+ earliest: int,
+) -> None:
+ """Spelling the values out reaches ``position`` from wherever that costs least."""
+ start = window.cheapest(position, earliest)
+ cost = shortest.costs[start] + literal_size(position - start)
+ if shortest.improves(position, cost):
+ shortest.relax(start, position, cost, LiteralToken(values=plane[start:position]))
+
+
+def _relax_forward(
+ shortest: Shortest,
+ index: PlaneIndex,
+ matcher: PhraseMatcher,
+ options: CodecOptions,
+ position: int,
+ reach: int,
+ *,
+ holdable: bool,
+) -> None:
+ """Everything a token starting at ``position`` may cover: a run held on, or a phrase played."""
+ plane = index.plane
+ cost = shortest.costs[position]
+ if options.holds and holdable and plane[position] == plane[position - 1]:
+ ticks = min(MAX_HOLD_TICKS, index.runs[position], reach)
+ shortest.relax(
+ position,
+ position + ticks,
+ cost + hold_size(),
+ HoldToken(ticks=ticks),
+ )
+
+ if not options.phrases:
+ return
+
+ for phrase_id, ticks, transpose in matcher.matches(
+ position,
+ min(MAX_PHRASE_TICKS, reach),
+ transposition=options.transposition,
+ ):
+ shortest.relax(
+ position,
+ position + ticks,
+ cost + phrase_size(phrase_id, transpose),
+ PhraseToken(phrase_id=phrase_id, ticks=ticks, transpose=transpose),
+ )
+
+
+def parse_plane(
+ matcher: PhraseMatcher,
+ options: CodecOptions,
+ boundaries: FrozenSet[int],
+) -> Parse:
+ """Reads a plane as the cheapest token stream the dictionary allows.
+
+ Every way of covering a tick is an edge — hold the value, spell it out, or play a phrase from
+ there — and the cheapest path across them all is the encoding. Costs are the bytes each token
+ takes, so the parse answers in the currency the program area is measured in.
+
+ A boundary is a tick a token starts on, which is how a loop entry stays reachable: the stream
+ is re-entered there, and a token that leans on the value the plane already reached starts
+ elsewhere.
+
+ Args:
+ matcher: The plane, alongside the phrases it may play.
+ options: Which of the codec's layers the encoding is built from.
+ boundaries: The ticks a token starts on.
+
+ Returns:
+ Parse: The tokens the plane is written as, and what each of its prefixes costs.
+ """
+ index = matcher.index
+ plane = index.plane
+ ticks = index.ticks
+ entries = Boundaries.across(ticks, boundaries)
+ previous = entries.previous
+ following = entries.following
+ shortest = Shortest.across(ticks)
+ window = LiteralWindow(shortest.costs)
+ _relax_forward(shortest, index, matcher, options, 0, following[0], holdable=False)
+ for position in range(1, ticks + 1):
+ _relax_literal(
+ shortest,
+ plane,
+ window,
+ position,
+ max(position - MAX_LITERAL_BYTES, previous[position]),
+ )
+ if position < ticks:
+ _relax_forward(
+ shortest,
+ index,
+ matcher,
+ options,
+ position,
+ following[position] - position,
+ holdable=position not in boundaries,
+ )
+
+ return Parse(tokens=shortest.walk(ticks), costs=tuple(shortest.costs))
diff --git a/src/sampletones_player/compression/parse/result.py b/src/sampletones_player/compression/parse/result.py
new file mode 100644
index 000000000..e7659c150
--- /dev/null
+++ b/src/sampletones_player/compression/parse/result.py
@@ -0,0 +1,22 @@
+from dataclasses import dataclass
+from typing import Tuple
+
+from sampletones_player.compression.tokens.types import TokenUnion
+
+
+@dataclass(frozen=True)
+class Parse:
+ """A plane read as tokens, alongside what each of its prefixes costs.
+
+ Attributes:
+ tokens: The tokens the plane is written as, in the order they are read.
+ costs: The bytes each prefix of the plane takes, the whole plane's cost last.
+ """
+
+ tokens: Tuple[TokenUnion, ...]
+ costs: Tuple[int, ...]
+
+ @property
+ def size(self) -> int:
+ """The bytes the plane's token stream takes."""
+ return self.costs[-1]
diff --git a/src/sampletones_player/compression/parse/shortest.py b/src/sampletones_player/compression/parse/shortest.py
new file mode 100644
index 000000000..be3bf09b7
--- /dev/null
+++ b/src/sampletones_player/compression/parse/shortest.py
@@ -0,0 +1,87 @@
+from __future__ import annotations
+
+from dataclasses import dataclass
+from typing import Final, List, Optional, Tuple
+
+from sampletones_player.compression.tokens.types import TokenUnion
+
+UNREACHED: Final[int] = -1
+
+
+@dataclass
+class Shortest:
+ """The cheapest way found so far of reaching each tick of a plane.
+
+ Every token is an edge from the tick it starts on to the tick after the ones it covers, and
+ its cost is the bytes it takes, so the cheapest path across the plane is its encoding. Each
+ tick remembers what it cost to reach and the token that reached it, which is what lets the
+ tokens be read back once the far end is settled.
+
+ Attributes:
+ costs: The bytes reaching each tick takes, ticks not yet reached holding ``UNREACHED``.
+ origins: The tick each one was reached from.
+ tokens: The token each tick was reached by.
+ """
+
+ costs: List[int]
+ origins: List[int]
+ tokens: List[Optional[TokenUnion]]
+
+ @classmethod
+ def across(cls, ticks: int) -> Shortest:
+ """Opens a search over a plane of ``ticks`` ticks, its first tick free to reach."""
+ return cls(
+ costs=[0] + [UNREACHED] * ticks,
+ origins=[0] * (ticks + 1),
+ tokens=[None] * (ticks + 1),
+ )
+
+ def improves(self, end: int, cost: int) -> bool:
+ """Whether reaching ``end`` for ``cost`` beats what it has been reached for so far."""
+ return self.costs[end] == UNREACHED or cost < self.costs[end]
+
+ def relax(
+ self,
+ start: int,
+ end: int,
+ cost: int,
+ token: TokenUnion,
+ ) -> None:
+ """Takes a token as the way to reach ``end`` where it is the cheapest one found.
+
+ Args:
+ start: The tick the token starts on.
+ end: The tick following the ones the token covers.
+ cost: The bytes reaching ``end`` through this token takes.
+ token: The token covering the ticks between the two.
+ """
+ if self.costs[end] != UNREACHED and self.costs[end] <= cost:
+ return
+
+ self.costs[end] = cost
+ self.origins[end] = start
+ self.tokens[end] = token
+
+ def walk(self, ticks: int) -> Tuple[TokenUnion, ...]:
+ """Reads the tokens of the cheapest path back from ``ticks`` to the plane's first tick.
+
+ Args:
+ ticks: The tick the path ends at, which is the ticks the plane covers.
+
+ Returns:
+ Tuple[TokenUnion, ...]: The tokens, in the order they are read.
+
+ Raises:
+ ValueError: If a tick along the path was never reached.
+ """
+ read: List[TokenUnion] = []
+ position = ticks
+ while position > 0:
+ token = self.tokens[position]
+ if token is None:
+ raise ValueError(f"tick {position} of the plane was left unreachable")
+
+ read.append(token)
+ position = self.origins[position]
+
+ return tuple(reversed(read))
diff --git a/src/sampletones_player/compression/parse/song.py b/src/sampletones_player/compression/parse/song.py
new file mode 100644
index 000000000..c1914b965
--- /dev/null
+++ b/src/sampletones_player/compression/parse/song.py
@@ -0,0 +1,87 @@
+from typing import FrozenSet, List, Sequence, Tuple
+
+from sampletones_player.compression.dictionary.phrase import Phrase
+from sampletones_player.compression.dictionary.table import PhraseTable
+from sampletones_player.compression.matches.cache import MatchCache
+from sampletones_player.compression.matches.matcher import PhraseMatcher
+from sampletones_player.compression.options import CodecOptions
+from sampletones_player.compression.parse.plane import parse_plane
+from sampletones_player.compression.parse.result import Parse
+from sampletones_player.compression.progress.monitor import CodecMonitor
+
+
+def parse_planes(
+ cache: MatchCache,
+ table: PhraseTable,
+ options: CodecOptions,
+ boundaries: FrozenSet[int],
+ monitor: CodecMonitor,
+) -> Tuple[Parse, ...]:
+ """Reads every plane of a song against one dictionary.
+
+ A plane is where the run looks up: reading one is the longest stretch the codec spends
+ without a natural pause, so the monitor hears from it once per plane.
+
+ Args:
+ cache: The planes the song covers, alongside what each phrase plays against them.
+ table: The phrases the planes may play.
+ options: Which of the codec's layers the encoding is built from.
+ boundaries: The ticks a token starts on.
+ monitor: Carries the run's reckoning of itself onward.
+
+ Returns:
+ Tuple[Parse, ...]: One parse per plane, in the order the planes were given.
+
+ Raises:
+ OperationCanceled: If the run is no longer wanted.
+ """
+ parses: List[Parse] = []
+ for plane in range(len(cache.indices)):
+ parses.append(parse_plane(PhraseMatcher(table, plane, cache), options, boundaries))
+ monitor.poll()
+
+ return tuple(parses)
+
+
+def parse_planes_offered(
+ cache: MatchCache,
+ table: PhraseTable,
+ options: CodecOptions,
+ boundaries: FrozenSet[int],
+ monitor: CodecMonitor,
+ *,
+ parses: Sequence[Parse],
+ offered: Phrase,
+) -> Tuple[Parse, ...]:
+ """Reads again the planes ``offered`` reaches, carrying every other parse forward.
+
+ A phrase the plane never plays leaves that plane's tokens exactly as they were: an entry
+ appended to the table takes an id after every phrase already in it, so the ids the plane's
+ own tokens name are unchanged and the cheapest path across it is the one already found.
+ This is what lets the search weigh a candidate for the cost of the planes it touches.
+
+ Args:
+ cache: The planes the song covers, alongside what each phrase plays against them.
+ table: The phrases the planes may play, ``offered`` among them.
+ options: Which of the codec's layers the encoding is built from.
+ boundaries: The ticks a token starts on.
+ monitor: Carries the run's reckoning of itself onward.
+ parses: The parse each plane reached under the table before ``offered`` joined it.
+ offered: The phrase the table gained.
+
+ Returns:
+ Tuple[Parse, ...]: One parse per plane, in the order the planes were given.
+
+ Raises:
+ OperationCanceled: If the run is no longer wanted.
+ """
+ trial: List[Parse] = []
+ for plane in range(len(cache.indices)):
+ if not cache.reading(plane, offered).reaches(transposition=options.transposition):
+ trial.append(parses[plane])
+ continue
+
+ trial.append(parse_plane(PhraseMatcher(table, plane, cache), options, boundaries))
+ monitor.poll()
+
+ return tuple(trial)
diff --git a/src/sampletones_player/compression/pitch.py b/src/sampletones_player/compression/pitch.py
new file mode 100644
index 000000000..34636621a
--- /dev/null
+++ b/src/sampletones_player/compression/pitch.py
@@ -0,0 +1,74 @@
+from __future__ import annotations
+
+from typing import Dict, Final, Tuple
+
+from pydantic import BaseModel, ConfigDict
+
+from sampletones_core.timers.utils import get_timer_table
+from sampletones_player.specification.registers import (
+ MAX_REGISTER_VALUE,
+ TIMER_HIGH_SHIFT,
+)
+from sampletones_shared.constants.music import LIMIT_MAX_PITCH, LIMIT_MIN_PITCH
+from sampletones_shared.music import Tuning
+
+PITCH_COUNT: Final[int] = LIMIT_MAX_PITCH - LIMIT_MIN_PITCH + 1
+
+
+class PitchTable(BaseModel):
+ """The timer every pitch sounds at, indexed the way a channel's plane names a pitch.
+
+ A plane states a pitch as its distance above the lowest pitch the project reaches, and the
+ table resolves that index into the divider the hardware takes. Naming pitches rather than
+ dividers is what makes a phrase transposable: adding a semitone to an index moves a note,
+ where adding one to a timer means nothing.
+
+ Attributes:
+ timers: The timer for each pitch, from the lowest the project reaches upwards.
+ """
+
+ model_config = ConfigDict(extra="forbid", frozen=True)
+
+ timers: Tuple[int, ...]
+
+ @classmethod
+ def from_tuning(cls, tuning: Tuning) -> PitchTable:
+ """Builds the table the reconstruction's own generators are tuned by.
+
+ Args:
+ tuning: Where concert pitch sits for the song being written.
+
+ Returns:
+ PitchTable: The timer each pitch sounds at, in pitch order.
+ """
+ table = get_timer_table(tuning)
+ return cls(
+ timers=tuple(
+ table[pitch]
+ for pitch in range(
+ LIMIT_MIN_PITCH,
+ LIMIT_MAX_PITCH + 1,
+ )
+ )
+ )
+
+ @property
+ def indices(self) -> Dict[int, int]:
+ """The index each timer is written as, the lowest pitch sounding it standing for it.
+
+ Pitches beyond the divider's range share the timer they are clamped to, and they sound
+ alike, so one index stands for the whole group and a stream naming any of them resolves
+ back to the timer it was written from.
+ """
+ indices: Dict[int, int] = {}
+ for index, timer in enumerate(self.timers):
+ indices.setdefault(timer, index)
+
+ return indices
+
+ @property
+ def data(self) -> bytes:
+ """The table as the driver reads it: every low byte, then every high byte."""
+ low = bytes(timer & MAX_REGISTER_VALUE for timer in self.timers)
+ high = bytes(timer >> TIMER_HIGH_SHIFT for timer in self.timers)
+ return low + high
diff --git a/src/sampletones_player/compression/planes/__init__.py b/src/sampletones_player/compression/planes/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/src/sampletones_player/compression/planes/channel.py b/src/sampletones_player/compression/planes/channel.py
new file mode 100644
index 000000000..cbee94f83
--- /dev/null
+++ b/src/sampletones_player/compression/planes/channel.py
@@ -0,0 +1,66 @@
+from __future__ import annotations
+
+from typing import Tuple
+
+from pydantic import BaseModel, ConfigDict, model_validator
+
+
+class ChannelPlanes(BaseModel):
+ """One channel's ticks separated into the byte series it writes.
+
+ A channel writes two things each tick: how it sounds and what it sounds. Read tick by tick
+ those two braid together, and each turns over at its own pace — a volume envelope decays
+ while a pitch holds, a pitch walks while the timbre stays put. Kept apart, each is a series
+ that repeats and rests on its own terms, which is the form the codec reads them in.
+
+ Attributes:
+ control: The timbre byte each tick writes, volume riding in it where a channel has one.
+ value: The pitch each tick sounds, as an index into the pitch table, or the noise
+ channel's period byte.
+ """
+
+ model_config = ConfigDict(extra="forbid", frozen=True)
+
+ control: bytes
+ value: bytes
+
+ @model_validator(mode="after")
+ def _validate_every_plane_covers_the_same_ticks(self) -> ChannelPlanes:
+ lengths = {len(plane) for plane in self.ordered}
+ if len(lengths) > 1:
+ raise ValueError(f"a channel's planes cover the same ticks, and these cover {sorted(lengths)}")
+
+ if not self.control:
+ raise ValueError("a channel's planes cover at least one tick")
+
+ return self
+
+ @property
+ def ticks(self) -> int:
+ """The ticks the channel's planes cover."""
+ return len(self.control)
+
+ @property
+ def ordered(self) -> Tuple[bytes, ...]:
+ """The channel's planes, in the order the song block writes them."""
+ return (self.control, self.value)
+
+
+class TonePlanes(ChannelPlanes):
+ """A tone channel's ticks, the divider each one sounds at named in two parts.
+
+ A tone channel reaches its divider through the pitch table, and a frame may stand away from
+ the note it names. The value plane holds the note, which is what lets a phrase be transposed
+ by adding to it, and the bend plane holds how far the frame stands from it — so the divider
+ the hardware takes is the sum, and each half repeats on its own terms.
+
+ Attributes:
+ bend: The divider steps each tick stands away from its note, held as a signed byte.
+ """
+
+ bend: bytes
+
+ @property
+ def ordered(self) -> Tuple[bytes, ...]:
+ """The channel's planes, in the order the song block writes them."""
+ return (self.control, self.value, self.bend)
diff --git a/src/sampletones_player/compression/planes/order.py b/src/sampletones_player/compression/planes/order.py
new file mode 100644
index 000000000..2291484af
--- /dev/null
+++ b/src/sampletones_player/compression/planes/order.py
@@ -0,0 +1,63 @@
+from __future__ import annotations
+
+from typing import Iterable, NamedTuple, Tuple
+
+from sampletones_player.specification.compression import PLANE_COUNT
+
+
+class PlaneOrder(NamedTuple):
+ """One byte series per plane, in the order the song block writes them.
+
+ The song block states its planes as a single run, and both readings of a song take that
+ shape: the values each plane plays tick by tick, and the tokens those values are written as.
+ Naming them is what lets either be carried whole and read back by the channel it belongs to.
+
+ Attributes:
+ pulse1_control: The first pulse channel's timbre and volume.
+ pulse1_value: The first pulse channel's pitch.
+ pulse1_bend: The first pulse channel's divider offset.
+ pulse2_control: The second pulse channel's timbre and volume.
+ pulse2_value: The second pulse channel's pitch.
+ pulse2_bend: The second pulse channel's divider offset.
+ triangle_control: The triangle channel's linear counter.
+ triangle_value: The triangle channel's pitch.
+ triangle_bend: The triangle channel's divider offset.
+ noise_control: The noise channel's timbre and volume.
+ noise_value: The noise channel's period.
+ """
+
+ pulse1_control: bytes
+ pulse1_value: bytes
+ pulse1_bend: bytes
+ pulse2_control: bytes
+ pulse2_value: bytes
+ pulse2_bend: bytes
+ triangle_control: bytes
+ triangle_value: bytes
+ triangle_bend: bytes
+ noise_control: bytes
+ noise_value: bytes
+
+ @classmethod
+ def names(cls) -> Tuple[str, ...]:
+ """The planes' names, in the order the song block writes them."""
+ return cls._fields
+
+ @classmethod
+ def across(cls, planes: Iterable[bytes]) -> PlaneOrder:
+ """Gathers a song's planes under the names the song block writes them by.
+
+ Args:
+ planes: The planes, in the order the song block writes them.
+
+ Returns:
+ PlaneOrder: The planes, each under its own name.
+
+ Raises:
+ ValueError: If the planes given are other than the ones a song block holds.
+ """
+ gathered = tuple(planes)
+ if len(gathered) != PLANE_COUNT:
+ raise ValueError(f"a song block holds {PLANE_COUNT} planes, and these are {len(gathered)}")
+
+ return cls._make(gathered)
diff --git a/src/sampletones_player/compression/planes/rebuild.py b/src/sampletones_player/compression/planes/rebuild.py
new file mode 100644
index 000000000..94c0bac54
--- /dev/null
+++ b/src/sampletones_player/compression/planes/rebuild.py
@@ -0,0 +1,94 @@
+from typing import Iterator, Tuple
+
+from sampletones_player.compression.pitch import PitchTable
+from sampletones_player.compression.planes.channel import ChannelPlanes, TonePlanes
+from sampletones_player.compression.planes.song import SongPlanes
+from sampletones_player.registers.noise import NoiseRegisters
+from sampletones_player.registers.pulse import PulseRegisters
+from sampletones_player.registers.streams import ChannelStreams
+from sampletones_player.registers.triangle import TriangleRegisters
+from sampletones_player.specification.binary import signed_byte
+from sampletones_player.specification.registers import (
+ MAX_REGISTER_VALUE,
+ TIMER_HIGH_SHIFT,
+)
+
+
+def _sounded(
+ planes: TonePlanes,
+ timers: Tuple[int, ...],
+) -> Iterator[Tuple[int, int]]:
+ """Each tick's control byte beside the divider its note and its bend reach together.
+
+ This is the reading the driver performs between the pitch table and the timer registers,
+ stated where it is testable.
+
+ Args:
+ planes: The channel's planes.
+ timers: The divider each pitch sounds at, in pitch order.
+
+ Yields:
+ Tuple[int, int]: The tick's control byte and the divider its registers carry.
+ """
+ for control, index, bend in zip(planes.control, planes.value, planes.bend):
+ yield control, timers[index] + signed_byte(bend)
+
+
+def _pulse_registers(
+ planes: TonePlanes,
+ timers: Tuple[int, ...],
+) -> Tuple[PulseRegisters, ...]:
+ return tuple(
+ PulseRegisters(
+ control=control,
+ timer_low=timer & MAX_REGISTER_VALUE,
+ timer_high=timer >> TIMER_HIGH_SHIFT,
+ )
+ for control, timer in _sounded(planes, timers)
+ )
+
+
+def _triangle_registers(
+ planes: TonePlanes,
+ timers: Tuple[int, ...],
+) -> Tuple[TriangleRegisters, ...]:
+ return tuple(
+ TriangleRegisters(
+ linear_counter=control,
+ timer_low=timer & MAX_REGISTER_VALUE,
+ timer_high=timer >> TIMER_HIGH_SHIFT,
+ )
+ for control, timer in _sounded(planes, timers)
+ )
+
+
+def _noise_registers(planes: ChannelPlanes) -> Tuple[NoiseRegisters, ...]:
+ return tuple(
+ NoiseRegisters(
+ control=control,
+ period=period,
+ )
+ for control, period in zip(planes.control, planes.value)
+ )
+
+
+def streams_from_planes(
+ planes: SongPlanes,
+ pitches: PitchTable,
+) -> ChannelStreams:
+ """Rebuilds a song's four streams from the planes they were separated into.
+
+ Args:
+ planes: The planes under the channel each belongs to.
+ pitches: The timer each pitch sounds at.
+
+ Returns:
+ ChannelStreams: The per-tick register values every channel plays.
+ """
+ timers = pitches.timers
+ return ChannelStreams(
+ pulse1=_pulse_registers(planes.pulse1, timers),
+ pulse2=_pulse_registers(planes.pulse2, timers),
+ triangle=_triangle_registers(planes.triangle, timers),
+ noise=_noise_registers(planes.noise),
+ )
diff --git a/src/sampletones_player/compression/planes/separate.py b/src/sampletones_player/compression/planes/separate.py
new file mode 100644
index 000000000..cd666d7d7
--- /dev/null
+++ b/src/sampletones_player/compression/planes/separate.py
@@ -0,0 +1,98 @@
+from typing import Dict, Final, Sequence
+
+from sampletones_core.constants.enums import ChannelName
+from sampletones_player.compression.pitch import PitchTable
+from sampletones_player.compression.planes.channel import ChannelPlanes, TonePlanes
+from sampletones_player.compression.planes.song import SongPlanes
+from sampletones_player.registers.base import ChannelRegisters
+from sampletones_player.registers.streams import ChannelStreams
+from sampletones_player.specification.channels import TONE_CHANNELS
+from sampletones_player.specification.registers import TIMER_HIGH_SHIFT
+
+CONTROL_VALUE_INDEX: Final[int] = 0
+FIRST_VALUE_INDEX: Final[int] = 1
+SECOND_VALUE_INDEX: Final[int] = 2
+
+
+def _pitch_indices(
+ registers: Sequence[ChannelRegisters],
+ indices: Dict[int, int],
+) -> bytes:
+ try:
+ return bytes(
+ indices[tick.values[FIRST_VALUE_INDEX] | (tick.values[SECOND_VALUE_INDEX] << TIMER_HIGH_SHIFT)]
+ for tick in registers
+ )
+ except KeyError as error:
+ raise ValueError(f"a channel sounds timer {error.args[0]}, which no pitch of the table sounds") from error
+
+
+def _tone_planes(
+ registers: Sequence[ChannelRegisters],
+ indices: Dict[int, int],
+) -> TonePlanes:
+ control = bytes(tick.values[CONTROL_VALUE_INDEX] for tick in registers)
+ return TonePlanes(
+ control=control,
+ value=_pitch_indices(registers, indices),
+ bend=bytes(len(registers)),
+ )
+
+
+def _noise_planes(registers: Sequence[ChannelRegisters]) -> ChannelPlanes:
+ control = bytes(tick.values[CONTROL_VALUE_INDEX] for tick in registers)
+ value = bytes(tick.values[FIRST_VALUE_INDEX] for tick in registers)
+ return ChannelPlanes(control=control, value=value)
+
+
+def channel_planes(
+ channel: ChannelName,
+ registers: Sequence[ChannelRegisters],
+ pitches: PitchTable,
+) -> ChannelPlanes:
+ """Separates one channel's ticks into the planes the codec reads.
+
+ Args:
+ channel: The channel the registers belong to.
+ registers: The channel's per-tick register values.
+ pitches: The timer each pitch sounds at.
+
+ Returns:
+ ChannelPlanes: The channel's own planes.
+
+ Raises:
+ ValueError: If a tone channel sounds a timer the pitch table states no index for.
+ """
+ if channel in TONE_CHANNELS:
+ return _tone_planes(registers, pitches.indices)
+
+ return _noise_planes(registers)
+
+
+def planes_from_streams(
+ streams: ChannelStreams,
+ pitches: PitchTable,
+) -> SongPlanes:
+ """Separates a song's four streams into the planes the codec compresses.
+
+ Every channel is carried to the song's full length first, so every plane covers the same
+ ticks and the decoder advances them together.
+
+ Args:
+ streams: The per-tick register values every channel plays.
+ pitches: The timer each pitch sounds at.
+
+ Returns:
+ SongPlanes: The planes under the channel each belongs to.
+
+ Raises:
+ ValueError: If a tone channel sounds a timer the pitch table states no index for.
+ """
+ indices = pitches.indices
+ pulse1, pulse2, triangle, noise = streams.padded
+ return SongPlanes(
+ pulse1=_tone_planes(pulse1, indices),
+ pulse2=_tone_planes(pulse2, indices),
+ triangle=_tone_planes(triangle, indices),
+ noise=_noise_planes(noise),
+ )
diff --git a/src/sampletones_player/compression/planes/song.py b/src/sampletones_player/compression/planes/song.py
new file mode 100644
index 000000000..05122d2ce
--- /dev/null
+++ b/src/sampletones_player/compression/planes/song.py
@@ -0,0 +1,93 @@
+from __future__ import annotations
+
+from typing import Tuple
+
+from pydantic import BaseModel, ConfigDict, model_validator
+
+from sampletones_player.compression.planes.channel import ChannelPlanes, TonePlanes
+from sampletones_player.compression.planes.order import PlaneOrder
+
+
+class SongPlanes(BaseModel):
+ """Every channel of a song separated into planes, the whole of what the codec compresses.
+
+ Attributes:
+ pulse1: The first pulse channel's planes.
+ pulse2: The second pulse channel's planes.
+ triangle: The triangle channel's planes.
+ noise: The noise channel's planes.
+ """
+
+ model_config = ConfigDict(extra="forbid", frozen=True)
+
+ pulse1: TonePlanes
+ pulse2: TonePlanes
+ triangle: TonePlanes
+ noise: ChannelPlanes
+
+ @classmethod
+ def from_order(cls, planes: PlaneOrder) -> SongPlanes:
+ """Gathers a song block's planes back into the four channels that write them.
+
+ Args:
+ planes: The planes, in the order the song block writes them.
+
+ Returns:
+ SongPlanes: The planes under the channel each belongs to.
+ """
+ return cls(
+ pulse1=TonePlanes(
+ control=planes.pulse1_control,
+ value=planes.pulse1_value,
+ bend=planes.pulse1_bend,
+ ),
+ pulse2=TonePlanes(
+ control=planes.pulse2_control,
+ value=planes.pulse2_value,
+ bend=planes.pulse2_bend,
+ ),
+ triangle=TonePlanes(
+ control=planes.triangle_control,
+ value=planes.triangle_value,
+ bend=planes.triangle_bend,
+ ),
+ noise=ChannelPlanes(
+ control=planes.noise_control,
+ value=planes.noise_value,
+ ),
+ )
+
+ @model_validator(mode="after")
+ def _validate_every_channel_reaches_the_same_tick(self) -> SongPlanes:
+ lengths = {channels.ticks for channels in self.ordered}
+ if len(lengths) > 1:
+ raise ValueError(f"a song's channels cover the same ticks, and these cover {sorted(lengths)}")
+
+ return self
+
+ @property
+ def ordered(self) -> Tuple[ChannelPlanes, ...]:
+ """The four channels in the order the generator names run."""
+ return (self.pulse1, self.pulse2, self.triangle, self.noise)
+
+ @property
+ def planes(self) -> PlaneOrder:
+ """Every plane in the order the song block writes them."""
+ return PlaneOrder(
+ pulse1_control=self.pulse1.control,
+ pulse1_value=self.pulse1.value,
+ pulse1_bend=self.pulse1.bend,
+ pulse2_control=self.pulse2.control,
+ pulse2_value=self.pulse2.value,
+ pulse2_bend=self.pulse2.bend,
+ triangle_control=self.triangle.control,
+ triangle_value=self.triangle.value,
+ triangle_bend=self.triangle.bend,
+ noise_control=self.noise.control,
+ noise_value=self.noise.value,
+ )
+
+ @property
+ def ticks(self) -> int:
+ """The ticks the song lasts."""
+ return self.pulse1.ticks
diff --git a/src/sampletones_player/compression/progress/__init__.py b/src/sampletones_player/compression/progress/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/src/sampletones_player/compression/progress/monitor.py b/src/sampletones_player/compression/progress/monitor.py
new file mode 100644
index 000000000..c38c31864
--- /dev/null
+++ b/src/sampletones_player/compression/progress/monitor.py
@@ -0,0 +1,53 @@
+from typing import Final
+
+from sampletones_player.compression.progress.report import CodecProgress, CodecReporter
+from sampletones_shared.exceptions import OperationCanceled
+
+NOTHING_FOUND: Final[int] = 0
+NOTHING_LAID_DOWN: Final[int] = 0
+
+
+class CodecMonitor:
+ """Carries an encoding run's reckoning of itself to whoever asked for the run.
+
+ Compressing a song of minutes takes seconds, and how many is decided by the song rather than
+ by anything the caller can work out beforehand, so the run looks up at the points where it
+ has something to say: after each plane it reads, after each phrase the search earns, and
+ after each round the table settles through. The monitor keeps what the run last reached, so a
+ stretch that has yet to reach a new figure still reports a true one and still asks whether the
+ answer is wanted.
+ """
+
+ def __init__(self, report: CodecReporter) -> None:
+ self._report = report
+ self._progress = CodecProgress(phrases=NOTHING_FOUND, size=NOTHING_LAID_DOWN)
+
+ @property
+ def progress(self) -> CodecProgress:
+ """What the run last reached."""
+ return self._progress
+
+ def reached(self, phrases: int, size: int) -> None:
+ """Records a reading of the whole song and offers it onward.
+
+ Args:
+ phrases: The entries the dictionary now holds.
+ size: The bytes the dictionary and every plane's stream now take together.
+
+ Raises:
+ OperationCanceled: If the run is no longer wanted.
+ """
+ self._progress = CodecProgress(phrases=phrases, size=size)
+ self.poll()
+
+ def poll(self) -> None:
+ """Offers what the run last reached, which is how a long stretch answers a withdrawal.
+
+ Raises:
+ OperationCanceled: If the run is no longer wanted.
+ """
+ if not self._report(self._progress):
+ raise OperationCanceled(
+ f"the encoding was withdrawn holding {self._progress.phrases} phrases "
+ f"and {self._progress.size} bytes"
+ )
diff --git a/src/sampletones_player/compression/progress/report.py b/src/sampletones_player/compression/progress/report.py
new file mode 100644
index 000000000..4ad840526
--- /dev/null
+++ b/src/sampletones_player/compression/progress/report.py
@@ -0,0 +1,19 @@
+from dataclasses import dataclass
+from typing import Callable
+
+
+@dataclass(frozen=True)
+class CodecProgress:
+ """What an encoding run holds at the moment it looks up from its work.
+
+ Attributes:
+ phrases: The entries the dictionary has gathered.
+ size: The bytes the dictionary and every plane's token stream take together, as of the last
+ reading of the whole song; a run that has yet to read one reports nothing laid down.
+ """
+
+ phrases: int
+ size: int
+
+
+CodecReporter = Callable[[CodecProgress], bool]
diff --git a/src/sampletones_player/compression/search.py b/src/sampletones_player/compression/search.py
new file mode 100644
index 000000000..830e350cf
--- /dev/null
+++ b/src/sampletones_player/compression/search.py
@@ -0,0 +1,203 @@
+from typing import Dict, Final, FrozenSet, List, NamedTuple, Sequence
+
+from sampletones_player.compression.dictionary.phrase import Phrase, phrase_entry_size
+from sampletones_player.compression.dictionary.table import PhraseTable, phrase_table
+from sampletones_player.compression.matches.cache import MatchCache
+from sampletones_player.compression.matches.index import PlaneIndex
+from sampletones_player.compression.options import CodecOptions
+from sampletones_player.compression.parse.result import Parse
+from sampletones_player.compression.parse.song import parse_planes, parse_planes_offered
+from sampletones_player.compression.progress.monitor import CodecMonitor
+from sampletones_player.compression.tokens.literal import LiteralToken
+from sampletones_player.compression.tokens.sizes import phrase_size
+from sampletones_player.specification.compression import MAX_PHRASE_IDS
+
+MIN_CANDIDATE_LENGTH: Final[int] = 3
+MAX_CANDIDATE_LENGTH: Final[int] = 48
+MAX_CANDIDATE_ENTRIES: Final[int] = 200_000
+MAX_SEARCH_ROUNDS: Final[int] = 64
+CONFIRMED_CANDIDATES: Final[int] = 3
+MIN_OCCURRENCES: Final[int] = 2
+UNSHIFTED_OCCURRENCE_TRANSPOSE: Final[int] = 0
+SHIFTED_OCCURRENCE_TRANSPOSE: Final[int] = 1
+
+
+class _Span(NamedTuple):
+ start: int
+ end: int
+
+
+class _Occurrence(NamedTuple):
+ plane: int
+ position: int
+
+
+class _Candidate(NamedTuple):
+ gain: int
+ body: bytes
+
+
+def _residue_spans(parse: Parse) -> List[_Span]:
+ spans: List[_Span] = []
+ position = 0
+ for token in parse.tokens:
+ if isinstance(token, LiteralToken):
+ spans.append(_Span(start=position, end=position + token.ticks))
+
+ position += token.ticks
+
+ return spans
+
+
+def _candidates(
+ indices: Sequence[PlaneIndex],
+ parses: Sequence[Parse],
+ monitor: CodecMonitor,
+) -> Dict[bytes, List[_Occurrence]]:
+ found: Dict[bytes, List[_Occurrence]] = {}
+ gather = found.setdefault
+ entries = 0
+ for plane, (index, parse) in enumerate(zip(indices, parses)):
+ monitor.poll()
+ differences = index.differences
+ for span in _residue_spans(parse):
+ if entries > MAX_CANDIDATE_ENTRIES:
+ return found
+
+ for position in range(span.start, span.end):
+ longest = min(MAX_CANDIDATE_LENGTH, span.end - position)
+ occurrence = _Occurrence(plane=plane, position=position)
+ for length in range(MIN_CANDIDATE_LENGTH, longest + 1):
+ gather(differences[position : position + length - 1], []).append(occurrence)
+
+ entries += max(0, longest - MIN_CANDIDATE_LENGTH + 1)
+
+ return found
+
+
+def _spread(
+ occurrences: Sequence[_Occurrence],
+ length: int,
+) -> List[_Occurrence]:
+ spread: List[_Occurrence] = []
+ reached = -1
+ covered = -1
+ for occurrence in occurrences:
+ if occurrence.plane != covered or occurrence.position >= reached:
+ spread.append(occurrence)
+ covered = occurrence.plane
+ reached = occurrence.position + length
+
+ return spread
+
+
+def _gain(
+ occurrences: Sequence[_Occurrence],
+ length: int,
+ parses: Sequence[Parse],
+ phrase_id: int,
+) -> int:
+ parsed = 0
+ for occurrence in occurrences:
+ costs = parses[occurrence.plane].costs
+ parsed += costs[occurrence.position + length] - costs[occurrence.position]
+
+ stated = phrase_size(phrase_id, UNSHIFTED_OCCURRENCE_TRANSPOSE)
+ shifted = phrase_size(phrase_id, SHIFTED_OCCURRENCE_TRANSPOSE)
+ return parsed - stated - shifted * (len(occurrences) - 1)
+
+
+def _ranked(
+ indices: Sequence[PlaneIndex],
+ parses: Sequence[Parse],
+ phrase_id: int,
+ monitor: CodecMonitor,
+) -> List[_Candidate]:
+ ranked: List[_Candidate] = []
+ for key, occurrences in _candidates(indices, parses, monitor).items():
+ if len(occurrences) < MIN_OCCURRENCES:
+ continue
+
+ length = len(key) + 1
+ spread = _spread(occurrences, length)
+ if len(spread) < MIN_OCCURRENCES:
+ continue
+
+ first = spread[0]
+ body = indices[first.plane].plane[first.position : first.position + length]
+ gain = _gain(spread, length, parses, phrase_id) - phrase_entry_size(length)
+ if gain > 0:
+ ranked.append(_Candidate(gain=gain, body=body))
+
+ ranked.sort(key=lambda candidate: (-candidate.gain, candidate.body))
+ return ranked[:CONFIRMED_CANDIDATES]
+
+
+def _total(table: PhraseTable, parses: Sequence[Parse]) -> int:
+ return table.size + sum(parse.size for parse in parses)
+
+
+def search_phrases(
+ cache: MatchCache,
+ table: PhraseTable,
+ options: CodecOptions,
+ boundaries: FrozenSet[int],
+ monitor: CodecMonitor,
+) -> PhraseTable:
+ """Fills the dictionary with the phrases the song's own planes repeat.
+
+ A candidate is scored by what the parse pays for it today against what a token naming it
+ would pay instead, the entry it takes in the dictionary included, so a run a hold already
+ covers for one byte scores nothing and the slots go to shapes that repeat at a price. The
+ best few candidates of each round are confirmed by parsing the whole song again with each
+ one added, and the round keeps whichever genuinely shrank the song.
+
+ Candidates are counted by their shape rather than their values, so a figure played at five
+ pitches is one candidate seen five times.
+
+ Args:
+ cache: The planes the song covers, alongside what each phrase plays against them.
+ table: The phrases the instruments seeded.
+ options: Which of the codec's layers the encoding is built from.
+ boundaries: The ticks a token starts on.
+ monitor: Carries the run's reckoning of itself onward.
+
+ Returns:
+ PhraseTable: The seeded phrases alongside the ones the search earned.
+
+ Raises:
+ OperationCanceled: If the run is no longer wanted.
+ """
+ indices = cache.indices
+ parses = parse_planes(cache, table, options, boundaries, monitor)
+ total = _total(table, parses)
+ monitor.reached(len(table), total)
+ for _ in range(MAX_SEARCH_ROUNDS):
+ if len(table) == MAX_PHRASE_IDS:
+ return table
+
+ settled = False
+ for candidate in _ranked(indices, parses, len(table), monitor):
+ offered = Phrase(body=candidate.body)
+ enlarged = phrase_table(table.phrases + (offered,))
+ trial = parse_planes_offered(
+ cache,
+ enlarged,
+ options,
+ boundaries,
+ monitor,
+ parses=parses,
+ offered=offered,
+ )
+ if _total(enlarged, trial) < total:
+ table = enlarged
+ parses = trial
+ total = _total(enlarged, trial)
+ monitor.reached(len(table), total)
+ settled = True
+ break
+
+ if not settled:
+ return table
+
+ return table
diff --git a/src/sampletones_player/compression/seeds.py b/src/sampletones_player/compression/seeds.py
new file mode 100644
index 000000000..dbfe7bc55
--- /dev/null
+++ b/src/sampletones_player/compression/seeds.py
@@ -0,0 +1,54 @@
+from typing import List, Tuple
+
+from sampletones_core.exporters.maps import CHANNEL_TO_EXPORTER_MAP
+from sampletones_core.exporters.slices import iterate_voice_slices
+from sampletones_core.project.project import Project
+from sampletones_core.timers.utils import get_timer_table
+from sampletones_player.compression.dictionary.phrase import Phrase
+from sampletones_player.compression.pitch import PitchTable
+from sampletones_player.compression.planes.separate import channel_planes
+from sampletones_player.registers.channel import channel_registers
+from sampletones_player.specification.compression import MAX_PHRASE_LENGTH
+from sampletones_shared.music import Tuning
+
+
+def phrases_from_project(
+ project: Project,
+ tuning: Tuning,
+) -> Tuple[Phrase, ...]:
+ """The phrases a project's own instruments offer the dictionary.
+
+ A song is built by playing sample slices at rows, so the shapes its planes repeat are the
+ slices themselves: each one reaches the dictionary as the planes it writes, at the pitch and
+ level it was reconstructed at, and every row playing it names those entries at the shift the
+ row asks for.
+
+ A plane holding one value throughout offers the dictionary nothing a hold covers more
+ cheaply, so the slices seed the planes that turn over.
+
+ Args:
+ project: The project whose samples the song plays.
+ tuning: Where concert pitch sits, which decides the timer each pitch sounds at.
+
+ Returns:
+ Tuple[Phrase, ...]: The phrases, in instrument-table order.
+ """
+ timer_table = get_timer_table(tuning)
+ pitches = PitchTable.from_tuning(tuning)
+ phrases: List[Phrase] = []
+ for voice_slice in iterate_voice_slices(project):
+ channel = voice_slice.channel
+ played = {channel: CHANNEL_TO_EXPORTER_MAP[channel].from_features(voice_slice.features)}
+ planes = channel_planes(
+ channel,
+ channel_registers(channel, played, timer_table),
+ pitches,
+ )
+ phrases.extend(Phrase(body=plane[:MAX_PHRASE_LENGTH]) for plane in planes.ordered if _turns_over(plane))
+
+ return tuple(phrases)
+
+
+def _turns_over(plane: bytes) -> bool:
+ """Whether a plane reaches more than one value over the ticks it covers."""
+ return len(set(plane)) > 1
diff --git a/src/sampletones_player/compression/song.py b/src/sampletones_player/compression/song.py
new file mode 100644
index 000000000..7a38582a7
--- /dev/null
+++ b/src/sampletones_player/compression/song.py
@@ -0,0 +1,77 @@
+from typing import FrozenSet, Optional, Sequence
+
+from sampletones_player.compression.compressed import CompressedPlanes
+from sampletones_player.compression.decode import decode_planes
+from sampletones_player.compression.dictionary.phrase import Phrase
+from sampletones_player.compression.encode import encode_planes
+from sampletones_player.compression.options import EVERY_LAYER
+from sampletones_player.compression.pitch import PitchTable
+from sampletones_player.compression.planes.rebuild import streams_from_planes
+from sampletones_player.compression.planes.separate import planes_from_streams
+from sampletones_player.compression.progress.report import CodecReporter
+from sampletones_player.registers.streams import ChannelStreams
+from sampletones_shared.utils.progress import silent_reporter
+
+
+def _entries(loop_tick: Optional[int]) -> FrozenSet[int]:
+ if loop_tick is None:
+ return frozenset()
+
+ return frozenset({loop_tick})
+
+
+def compress_song(
+ streams: ChannelStreams,
+ pitches: PitchTable,
+ *,
+ seeds: Sequence[Phrase],
+ loop_tick: Optional[int] = None,
+ report: CodecReporter = silent_reporter,
+) -> CompressedPlanes:
+ """Compresses a song's four register streams into the dictionary and streams a file carries.
+
+ A song that repeats re-enters its streams partway through, so the tick it returns to holds a
+ token of its own on every plane: what the driver needs to resume there is a source pointer,
+ and a token that leans on the value a plane already reached would need the run that led to it.
+
+ Args:
+ streams: The per-tick register values every channel plays.
+ pitches: The timer each pitch sounds at, which is what turns a timer into an index.
+ seeds: The phrases the song's instruments offer the dictionary.
+ loop_tick: The tick the song returns to once it ends, or ``None`` where it stops there.
+ report: Hears what the codec holds each time it looks up, and answers whether it goes on.
+
+ Returns:
+ CompressedPlanes: The dictionary, every plane's token stream and the ticks the song lasts.
+
+ Raises:
+ OperationCanceled: If ``report`` withdraws the run.
+ ValueError: If a stream sounds a timer the pitch table states no index for.
+ """
+ return encode_planes(
+ planes_from_streams(streams, pitches),
+ seeds,
+ options=EVERY_LAYER,
+ boundaries=_entries(loop_tick),
+ report=report,
+ )
+
+
+def decompress_song(
+ planes: CompressedPlanes,
+ pitches: PitchTable,
+) -> ChannelStreams:
+ """Plays a song's token streams back into the register values every channel writes.
+
+ This is the reading the driver performs, stated where it is testable: the trace the assembly
+ is held against is taken from it, so what the console plays and what the file holds are the
+ same values.
+
+ Args:
+ planes: The dictionary, every plane's token stream and the ticks the song lasts.
+ pitches: The timer each pitch sounds at, which is what turns an index back into a timer.
+
+ Returns:
+ ChannelStreams: The per-tick register values every channel plays.
+ """
+ return streams_from_planes(decode_planes(planes), pitches)
diff --git a/src/sampletones_player/compression/tokens/__init__.py b/src/sampletones_player/compression/tokens/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/src/sampletones_player/compression/tokens/hold.py b/src/sampletones_player/compression/tokens/hold.py
new file mode 100644
index 000000000..f03ab9a8d
--- /dev/null
+++ b/src/sampletones_player/compression/tokens/hold.py
@@ -0,0 +1,15 @@
+from dataclasses import dataclass
+
+from sampletones_player.compression.tokens.sizes import hold_size
+
+
+@dataclass(frozen=True)
+class HoldToken:
+ """The plane keeps the value it reached, for ``ticks`` ticks."""
+
+ ticks: int
+
+ @property
+ def size(self) -> int:
+ """The bytes the token takes."""
+ return hold_size()
diff --git a/src/sampletones_player/compression/tokens/literal.py b/src/sampletones_player/compression/tokens/literal.py
new file mode 100644
index 000000000..eb816f328
--- /dev/null
+++ b/src/sampletones_player/compression/tokens/literal.py
@@ -0,0 +1,20 @@
+from dataclasses import dataclass
+
+from sampletones_player.compression.tokens.sizes import literal_size
+
+
+@dataclass(frozen=True)
+class LiteralToken:
+ """The plane takes the values verbatim, one per tick."""
+
+ values: bytes
+
+ @property
+ def ticks(self) -> int:
+ """The ticks the token covers."""
+ return len(self.values)
+
+ @property
+ def size(self) -> int:
+ """The bytes the token takes."""
+ return literal_size(len(self.values))
diff --git a/src/sampletones_player/compression/tokens/phrase.py b/src/sampletones_player/compression/tokens/phrase.py
new file mode 100644
index 000000000..522606192
--- /dev/null
+++ b/src/sampletones_player/compression/tokens/phrase.py
@@ -0,0 +1,21 @@
+from dataclasses import dataclass
+
+from sampletones_player.compression.tokens.sizes import phrase_size
+
+
+@dataclass(frozen=True)
+class PhraseToken:
+ """The plane plays a phrase from the table, shifted by ``transpose``, for ``ticks`` ticks.
+
+ A count past the phrase's own length holds its final value onwards, the way a note whose
+ envelope has finished keeps sounding, and a count short of it cuts the note off.
+ """
+
+ phrase_id: int
+ ticks: int
+ transpose: int
+
+ @property
+ def size(self) -> int:
+ """The bytes the token takes."""
+ return phrase_size(self.phrase_id, self.transpose)
diff --git a/src/sampletones_player/compression/tokens/sizes.py b/src/sampletones_player/compression/tokens/sizes.py
new file mode 100644
index 000000000..80d946333
--- /dev/null
+++ b/src/sampletones_player/compression/tokens/sizes.py
@@ -0,0 +1,35 @@
+from sampletones_player.specification.compression import (
+ OPCODE_SIZE,
+ PHRASE_COUNT_SIZE,
+ PHRASE_ESCAPE_SIZE,
+ PHRASE_ID_ESCAPE,
+ TRANSPOSE_SIZE,
+)
+
+
+def hold_size() -> int:
+ """The bytes a hold takes, its opcode carrying the count."""
+ return OPCODE_SIZE
+
+
+def literal_size(length: int) -> int:
+ """The bytes a literal of ``length`` values takes, its opcode carrying the length."""
+ return OPCODE_SIZE + length
+
+
+def phrase_size(phrase_id: int, transpose: int) -> int:
+ """The bytes a phrase token takes.
+
+ The opcode carries the phrase's id where the id is one of the cheap ones, and a further byte
+ names it beyond those. A count byte follows, and a shifted phrase carries the shift as well.
+
+ Args:
+ phrase_id: Position the phrase takes in the table.
+ transpose: The shift every byte of the phrase is played at.
+
+ Returns:
+ int: The bytes the token takes.
+ """
+ escape = PHRASE_ESCAPE_SIZE if phrase_id >= PHRASE_ID_ESCAPE else 0
+ shift = TRANSPOSE_SIZE if transpose else 0
+ return OPCODE_SIZE + PHRASE_COUNT_SIZE + escape + shift
diff --git a/src/sampletones_player/compression/tokens/span.py b/src/sampletones_player/compression/tokens/span.py
new file mode 100644
index 000000000..bc67a7f5a
--- /dev/null
+++ b/src/sampletones_player/compression/tokens/span.py
@@ -0,0 +1,61 @@
+from typing import NamedTuple
+
+from sampletones_player.specification.compression import (
+ OPCODE_SIZE,
+ PHRASE_COUNT_SIZE,
+ PHRASE_ESCAPE_SIZE,
+ PHRASE_ID_ESCAPE,
+ TOKEN_OPERAND_MASK,
+ TOKEN_TAG_MASK,
+ TRANSPOSE_SIZE,
+ TokenTag,
+)
+
+
+class TokenSpan(NamedTuple):
+ """What one written token occupies in a stream and covers in the song.
+
+ Both readings of a token stream ask this: the driver to know where the next opcode lies, and
+ the writer to find the byte a loop re-enters the stream at. Neither needs the dictionary, so
+ the two figures follow from the opcode and the bytes behind it alone.
+
+ Attributes:
+ size: The bytes the token takes.
+ ticks: The ticks the token covers.
+ """
+
+ size: int
+ ticks: int
+
+
+def _phrase_span(data: bytes, position: int, *, transposed: bool) -> TokenSpan:
+ named = data[position] & TOKEN_OPERAND_MASK
+ escape = PHRASE_ESCAPE_SIZE if named == PHRASE_ID_ESCAPE else 0
+ count = position + OPCODE_SIZE + escape
+ shift = TRANSPOSE_SIZE if transposed else 0
+ return TokenSpan(
+ size=OPCODE_SIZE + escape + PHRASE_COUNT_SIZE + shift,
+ ticks=data[count] + 1,
+ )
+
+
+def token_span(data: bytes, position: int) -> TokenSpan:
+ """Reads the token written at ``position``, answering what it takes and what it covers.
+
+ Args:
+ data: The plane's token stream.
+ position: The byte the token's opcode lies at.
+
+ Returns:
+ TokenSpan: The bytes the token takes and the ticks it covers.
+ """
+ operand = data[position] & TOKEN_OPERAND_MASK
+ match TokenTag(data[position] & TOKEN_TAG_MASK):
+ case TokenTag.HOLD:
+ return TokenSpan(size=OPCODE_SIZE, ticks=operand + 1)
+ case TokenTag.LITERAL:
+ return TokenSpan(size=OPCODE_SIZE + operand + 1, ticks=operand + 1)
+ case TokenTag.PHRASE:
+ return _phrase_span(data, position, transposed=False)
+ case TokenTag.TRANSPOSED_PHRASE:
+ return _phrase_span(data, position, transposed=True)
diff --git a/src/sampletones_player/compression/tokens/types.py b/src/sampletones_player/compression/tokens/types.py
new file mode 100644
index 000000000..edeb685cb
--- /dev/null
+++ b/src/sampletones_player/compression/tokens/types.py
@@ -0,0 +1,7 @@
+from typing import Union
+
+from sampletones_player.compression.tokens.hold import HoldToken
+from sampletones_player.compression.tokens.literal import LiteralToken
+from sampletones_player.compression.tokens.phrase import PhraseToken
+
+TokenUnion = Union[HoldToken, LiteralToken, PhraseToken]
diff --git a/src/sampletones_player/driver/__init__.py b/src/sampletones_player/driver/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/src/sampletones_player/driver/addresses.py b/src/sampletones_player/driver/addresses.py
new file mode 100644
index 000000000..3e23b2926
--- /dev/null
+++ b/src/sampletones_player/driver/addresses.py
@@ -0,0 +1,48 @@
+from __future__ import annotations
+
+from pydantic import BaseModel, ConfigDict, Field
+
+from sampletones_player.specification.driver import (
+ INIT_ADDRESS,
+ LOAD_ADDRESS,
+ MAX_ADDRESS,
+ PLAY_ADDRESS,
+)
+
+
+class DriverAddresses(BaseModel):
+ """Where the driver and the song it carries sit in the console's address space.
+
+ Three of the four are settled before anything is assembled: the image loads at the start of
+ the program area, and a jump table leads it so both routines answer at a fixed address
+ whatever the driver's length. The song follows the code, so its address is the one value a
+ build decides, and it decides it by the number of bytes it produced.
+
+ Attributes:
+ load: Where the console loads the image.
+ init: The routine that readies the APU and sounds the song's first tick.
+ play: The routine the console calls at the rate the NSF header asks for.
+ song: Where the song block belongs, directly behind the code.
+ """
+
+ model_config = ConfigDict(extra="forbid", frozen=True)
+
+ load: int = Field(default=LOAD_ADDRESS, ge=0, le=MAX_ADDRESS)
+ init: int = Field(default=INIT_ADDRESS, ge=0, le=MAX_ADDRESS)
+ play: int = Field(default=PLAY_ADDRESS, ge=0, le=MAX_ADDRESS)
+ song: int = Field(..., ge=0, le=MAX_ADDRESS)
+
+ @classmethod
+ def for_code(cls, code_length: int) -> DriverAddresses:
+ """The addresses a driver of ``code_length`` bytes is built to answer at.
+
+ A build holds its linker's own labels against these, and the exporter reads them without
+ one, which is what keeps the committed image and the addresses that describe it in step.
+
+ Args:
+ code_length: The assembled driver's length in bytes.
+
+ Returns:
+ DriverAddresses: The addresses the image is expected to lay out.
+ """
+ return cls(song=LOAD_ADDRESS + code_length)
diff --git a/src/sampletones_player/driver/assembler/__init__.py b/src/sampletones_player/driver/assembler/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/src/sampletones_player/driver/assembler/builder.py b/src/sampletones_player/driver/assembler/builder.py
new file mode 100644
index 000000000..aae212b2c
--- /dev/null
+++ b/src/sampletones_player/driver/assembler/builder.py
@@ -0,0 +1,107 @@
+from pathlib import Path
+from tempfile import TemporaryDirectory
+from typing import Final, List
+
+from sampletones_player.driver.addresses import DriverAddresses
+from sampletones_player.driver.assembler.labels import read_addresses
+from sampletones_player.driver.assembler.layout import (
+ INCLUDE_DIRECTORY,
+ LINKER_CONFIGURATION,
+ SOURCE_DIRECTORY,
+ SOURCE_NAMES,
+)
+from sampletones_player.driver.assembler.toolchain import Toolchain
+from sampletones_player.driver.image import DriverImage
+from sampletones_player.specification.driver import DRIVER_CODE_NAME
+from sampletones_shared.exceptions import DriverBuildError
+
+LABELS_NAME: Final[str] = "driver.labels"
+OBJECT_SUFFIX: Final[str] = ".o"
+
+
+def build_driver(destination: Path) -> DriverImage:
+ """Assembles the driver and writes the image into ``destination``.
+
+ The image is held to the addresses the exporter reads it through before it reaches the
+ directory, so a build either produces the driver the package ships or produces nothing.
+ ``driver.s`` leads the sources because the linker lays a segment out in the order it receives
+ the object files, and the entry points are the first bytes of the image.
+
+ Args:
+ destination: The directory receiving ``driver.bin``.
+
+ Returns:
+ DriverImage: The assembled bytes and the addresses the linker laid them out at.
+
+ Raises:
+ ToolchainMissingError: If the cc65 programs are absent from the system.
+ DriverBuildError: If a program fails, or the layout departs from the one the driver is
+ built to answer at.
+ """
+ toolchain = Toolchain.locate()
+ with TemporaryDirectory() as directory:
+ work_directory = Path(directory)
+ objects = assemble_sources(toolchain, work_directory)
+ assembled = work_directory / DRIVER_CODE_NAME
+ labels = work_directory / LABELS_NAME
+ toolchain.link(LINKER_CONFIGURATION, objects, assembled, labels)
+ image = DriverImage(
+ code=assembled.read_bytes(),
+ addresses=read_addresses(labels),
+ )
+
+ verify_addresses(image)
+ destination.mkdir(parents=True, exist_ok=True)
+ (destination / DRIVER_CODE_NAME).write_bytes(image.code)
+ return image
+
+
+def assemble_sources(toolchain: Toolchain, work_directory: Path) -> List[Path]:
+ """Assembles every source the driver is built from.
+
+ Args:
+ toolchain: The cc65 programs the build runs.
+ work_directory: The directory receiving the object files.
+
+ Returns:
+ List[Path]: The object files, in the order they take in the image.
+
+ Raises:
+ DriverBuildError: If the assembler reports a failure.
+ """
+ objects: List[Path] = []
+ for name in SOURCE_NAMES:
+ object_file = (work_directory / name).with_suffix(OBJECT_SUFFIX)
+ toolchain.assemble(SOURCE_DIRECTORY / name, INCLUDE_DIRECTORY, object_file)
+ objects.append(object_file)
+
+ return objects
+
+
+def verify_addresses(image: DriverImage) -> None:
+ """Holds a build's own layout to the addresses the driver is built to answer at.
+
+ The exporter states those addresses without an assembler, so a build that laid the image out
+ elsewhere would leave the committed driver and the header describing it telling different
+ stories.
+
+ Args:
+ image: The assembled bytes and the addresses the linker reported for them.
+
+ Raises:
+ DriverBuildError: If any address departs from the one a driver of that length answers at.
+ """
+ expected = DriverAddresses.for_code(len(image.code))
+ mismatches = [
+ f"{name} at {reported:#06x} where the driver answers at {stated:#06x}"
+ for name, reported, stated in (
+ ("load", image.addresses.load, expected.load),
+ ("init", image.addresses.init, expected.init),
+ ("play", image.addresses.play, expected.play),
+ ("song", image.addresses.song, expected.song),
+ )
+ if reported != stated
+ ]
+
+ if mismatches:
+ raise DriverBuildError(f"the linker laid the driver out with {', '.join(mismatches)}")
diff --git a/src/sampletones_player/driver/assembler/labels.py b/src/sampletones_player/driver/assembler/labels.py
new file mode 100644
index 000000000..32f7a9c4b
--- /dev/null
+++ b/src/sampletones_player/driver/assembler/labels.py
@@ -0,0 +1,83 @@
+from pathlib import Path
+from typing import Dict, Final
+
+from sampletones_player.driver.addresses import DriverAddresses
+from sampletones_shared.constants.general import HEXADECIMAL_BASE
+from sampletones_shared.exceptions import DriverBuildError
+
+LABEL_MARKER: Final[str] = "al"
+LABEL_FIELDS: Final[int] = 3
+SYMBOL_PREFIX: Final[str] = "."
+
+LOAD_SYMBOL: Final[str] = "__PRG_START__"
+INIT_SYMBOL: Final[str] = "nsf_init"
+PLAY_SYMBOL: Final[str] = "nsf_play"
+SONG_SYMBOL: Final[str] = "song_data"
+
+
+def read_labels(path: Path) -> Dict[str, int]:
+ """Reads the addresses a linker reported for its symbols.
+
+ The label file lists one symbol per line as ``al .``, the format debuggers
+ read a build's symbols through. Lines of that shape carry the addresses; the rest of the file
+ describes the build itself.
+
+ Args:
+ path: The label file the linker wrote.
+
+ Returns:
+ Dict[str, int]: Each symbol's address, keyed by the symbol's own name.
+ """
+ labels: Dict[str, int] = {}
+ for line in path.read_text().splitlines():
+ fields = line.split()
+ if len(fields) == LABEL_FIELDS and fields[0] == LABEL_MARKER:
+ labels[fields[2].removeprefix(SYMBOL_PREFIX)] = int(
+ fields[1],
+ HEXADECIMAL_BASE,
+ )
+
+ return labels
+
+
+def read_addresses(path: Path) -> DriverAddresses:
+ """Reads where a linker laid the driver and the song behind it.
+
+ A build states its own layout this way, so what the exporter reads about the image comes from
+ the program that produced it.
+
+ Args:
+ path: The label file the linker wrote.
+
+ Returns:
+ DriverAddresses: The addresses the linker reported.
+
+ Raises:
+ DriverBuildError: If the linker reported no address for one of the four symbols.
+ """
+ labels = read_labels(path)
+ return DriverAddresses(
+ load=address_of(labels, LOAD_SYMBOL),
+ init=address_of(labels, INIT_SYMBOL),
+ play=address_of(labels, PLAY_SYMBOL),
+ song=address_of(labels, SONG_SYMBOL),
+ )
+
+
+def address_of(labels: Dict[str, int], symbol: str) -> int:
+ """The address a linker reported for one symbol.
+
+ Args:
+ labels: Each symbol's address, as :func:`read_labels` answers.
+ symbol: The symbol to look up.
+
+ Returns:
+ int: Where the symbol lies.
+
+ Raises:
+ DriverBuildError: If the linker reported no address for the symbol.
+ """
+ if symbol not in labels:
+ raise DriverBuildError(f"the linker reported no address for {symbol}")
+
+ return labels[symbol]
diff --git a/src/sampletones_player/driver/assembler/layout.py b/src/sampletones_player/driver/assembler/layout.py
new file mode 100644
index 000000000..6e312bbb7
--- /dev/null
+++ b/src/sampletones_player/driver/assembler/layout.py
@@ -0,0 +1,14 @@
+from pathlib import Path
+from typing import Final, Tuple
+
+from sampletones_player.specification.driver import DRIVER_BINARY_DIRECTORY
+from sampletones_shared.paths.source import SOURCE_ROOT
+
+DRIVER_DIRECTORY: Final[Path] = SOURCE_ROOT / "sampletones_player" / "driver"
+ASSEMBLY_DIRECTORY: Final[Path] = DRIVER_DIRECTORY / "assembly"
+INCLUDE_DIRECTORY: Final[Path] = ASSEMBLY_DIRECTORY / "include"
+SOURCE_DIRECTORY: Final[Path] = ASSEMBLY_DIRECTORY / "source"
+LINKER_CONFIGURATION: Final[Path] = ASSEMBLY_DIRECTORY / "nsf.cfg"
+BINARY_DIRECTORY: Final[Path] = DRIVER_DIRECTORY / DRIVER_BINARY_DIRECTORY
+
+SOURCE_NAMES: Final[Tuple[str, ...]] = ("driver.s", "clock.s", "channels.s")
diff --git a/src/sampletones_player/driver/assembler/toolchain.py b/src/sampletones_player/driver/assembler/toolchain.py
new file mode 100644
index 000000000..f27b47c8c
--- /dev/null
+++ b/src/sampletones_player/driver/assembler/toolchain.py
@@ -0,0 +1,151 @@
+from __future__ import annotations
+
+import subprocess
+from dataclasses import dataclass
+from pathlib import Path
+from typing import Dict, Final, List, Sequence
+
+from sampletones_shared.exceptions import DriverBuildError, ToolchainMissingError
+from sampletones_shared.utils.system.programs import (
+ locate_program,
+ missing_program_message,
+)
+from sampletones_shared.utils.system.system import System
+
+ASSEMBLER: Final[str] = "ca65"
+LINKER: Final[str] = "ld65"
+TARGET_CPU: Final[str] = "6502"
+ASSEMBLY_PURPOSE: Final[str] = "the player driver is assembled with cc65"
+
+INSTALL_HINTS: Final[Dict[System, str]] = {
+ System.LINUX: "sudo apt install cc65",
+ System.MACOS: "brew install cc65",
+ System.WINDOWS: "install cc65 from https://cc65.github.io and add its bin directory to PATH",
+}
+
+
+@dataclass(frozen=True)
+class Toolchain:
+ """The cc65 programs a driver build runs.
+
+ Locating both programs up front is what lets a build fail with an install hint before it
+ writes anything, and holding them as paths keeps every later call pointed at the same pair.
+
+ Attributes:
+ assembler: The ``ca65`` program, which turns one assembly source into an object file.
+ linker: The ``ld65`` program, which lays the object files out into the driver image.
+ """
+
+ assembler: Path
+ linker: Path
+
+ @classmethod
+ def locate(cls) -> Toolchain:
+ """The cc65 programs installed on this system.
+
+ Returns:
+ Toolchain: The assembler and the linker, each resolved to its path.
+
+ Raises:
+ ToolchainMissingError: If either program is absent, naming the way this system
+ installs cc65.
+ """
+ return cls(assembler=cls.find(ASSEMBLER), linker=cls.find(LINKER))
+
+ @staticmethod
+ def find(program: str) -> Path:
+ """Resolves one cc65 program to its path.
+
+ Args:
+ program: The program's name, as it answers on the command line.
+
+ Returns:
+ Path: Where the program is installed.
+
+ Raises:
+ ToolchainMissingError: If the program is absent from the system.
+ """
+ located = locate_program(program)
+ if located is None:
+ raise ToolchainMissingError(missing_program_message(program, ASSEMBLY_PURPOSE, INSTALL_HINTS))
+
+ return located
+
+ def assemble(self, source: Path, include_directory: Path, destination: Path) -> None:
+ """Assembles one 6502 source into an object file.
+
+ Args:
+ source: The assembly source to translate.
+ include_directory: Where the sources' ``.include`` files are found.
+ destination: The object file the assembler writes.
+
+ Raises:
+ DriverBuildError: If the assembler reports a failure.
+ """
+ self.run(
+ [
+ str(self.assembler),
+ "--cpu",
+ TARGET_CPU,
+ "--include-dir",
+ str(include_directory),
+ "-o",
+ str(destination),
+ str(source),
+ ],
+ )
+
+ def link(
+ self,
+ configuration: Path,
+ objects: Sequence[Path],
+ destination: Path,
+ labels: Path,
+ ) -> None:
+ """Lays the object files out into the driver image.
+
+ The line names our own configuration and our own object files alone, which is what keeps
+ the image entirely ours to ship: reaching for a cc65 target or library would place that
+ project's start-up code and routines in the bytes the package commits.
+
+ Args:
+ configuration: The linker configuration stating the memory layout.
+ objects: The object files, in the order they take in the image.
+ destination: The driver image the linker writes.
+ labels: The label file the linker writes, holding each symbol's address.
+
+ Raises:
+ DriverBuildError: If the linker reports a failure.
+ """
+ self.run(
+ [
+ str(self.linker),
+ "--config",
+ str(configuration),
+ "-Ln",
+ str(labels),
+ "-o",
+ str(destination),
+ *[str(object_file) for object_file in objects],
+ ],
+ )
+
+ @staticmethod
+ def run(command: Sequence[str]) -> None:
+ """Runs one cc65 program and answers for what it reported.
+
+ Args:
+ command: The program and its arguments.
+
+ Raises:
+ DriverBuildError: If the program exits with a failure, carrying what it printed.
+ """
+ arguments: List[str] = list(command)
+ completed = subprocess.run(
+ arguments,
+ capture_output=True,
+ text=True,
+ check=False,
+ )
+ if completed.returncode != 0:
+ raise DriverBuildError(f"{Path(arguments[0]).name} failed: {completed.stderr.strip()}")
diff --git a/src/sampletones_player/driver/assembly/include/nes.inc b/src/sampletones_player/driver/assembly/include/nes.inc
new file mode 100644
index 000000000..02073cb05
--- /dev/null
+++ b/src/sampletones_player/driver/assembly/include/nes.inc
@@ -0,0 +1,23 @@
+CHANNEL_CONTROL = $4000
+CHANNEL_TIMER_LOW = $4002
+CHANNEL_TIMER_HIGH = $4003
+
+PULSE1_REGISTERS = $00
+PULSE2_REGISTERS = $04
+TRIANGLE_REGISTERS = $08
+NOISE_REGISTERS = $0C
+
+PULSE1_SWEEP = $4001
+PULSE2_SWEEP = $4005
+NOISE_LENGTH_COUNTER = $400F
+APU_STATUS = $4015
+APU_FRAME_COUNTER = $4017
+
+FIRST_CHANNEL_REGISTER = $4000
+CHANNEL_REGISTER_COUNT = $14
+
+SILENCED_REGISTER = $00
+CHANNELS_ENABLED = $0F
+FRAME_COUNTER_SEQUENCE = $40
+SWEEP_DISABLED = $08
+NOISE_LENGTH_COUNTER_LOAD = $00
diff --git a/src/sampletones_player/driver/assembly/include/song.inc b/src/sampletones_player/driver/assembly/include/song.inc
new file mode 100644
index 000000000..e1b66d884
--- /dev/null
+++ b/src/sampletones_player/driver/assembly/include/song.inc
@@ -0,0 +1,54 @@
+WORD_SIZE = 2
+PLANE_COUNT = 11
+
+STEP_WHOLE_OFFSET = 0
+STEP_FRACTION_OFFSET = STEP_WHOLE_OFFSET + 1
+TOTAL_TICKS_OFFSET = STEP_FRACTION_OFFSET + WORD_SIZE
+LOOP_TICK_OFFSET = TOTAL_TICKS_OFFSET + WORD_SIZE
+TIMER_TABLE_OFFSET = LOOP_TICK_OFFSET + WORD_SIZE
+PHRASE_TABLE_OFFSET = TIMER_TABLE_OFFSET + WORD_SIZE
+STREAM_OFFSETS_OFFSET = PHRASE_TABLE_OFFSET + WORD_SIZE
+LOOP_ENTRIES_OFFSET = STREAM_OFFSETS_OFFSET + WORD_SIZE * PLANE_COUNT
+
+NO_LOOP = $FFFF
+
+TICK_FINISHED = 0
+TICK_PLAYS = 1
+TICK_REPEATED = 2
+
+PITCH_COUNT = 104
+
+TOKEN_TAG_MASK = $C0
+TOKEN_OPERAND_MASK = $3F
+TAG_HOLD = $00
+TAG_LITERAL = $40
+TAG_PHRASE = $80
+TAG_TRANSPOSED_PHRASE = $C0
+
+OPCODE_SIZE = 1
+PHRASE_ID_ESCAPE = $3F
+PHRASE_TABLE_COUNT_SIZE = 1
+PHRASE_TABLE_ENTRY_SIZE = 2
+PHRASE_LENGTH_SIZE = 1
+
+PLANE_STATE_SIZE = 8
+PLANE_SOURCE = 0
+PLANE_PHRASE = 2
+PLANE_PHRASE_TICKS = 4
+PLANE_TOKEN_TICKS = 5
+PLANE_VALUE = 6
+PLANE_SHIFT = 7
+
+PLANE_STATE_BYTES = PLANE_COUNT * PLANE_STATE_SIZE
+
+PULSE1_CONTROL_PLANE = 0 * PLANE_STATE_SIZE
+PULSE1_VALUE_PLANE = 1 * PLANE_STATE_SIZE
+PULSE1_BEND_PLANE = 2 * PLANE_STATE_SIZE
+PULSE2_CONTROL_PLANE = 3 * PLANE_STATE_SIZE
+PULSE2_VALUE_PLANE = 4 * PLANE_STATE_SIZE
+PULSE2_BEND_PLANE = 5 * PLANE_STATE_SIZE
+TRIANGLE_CONTROL_PLANE = 6 * PLANE_STATE_SIZE
+TRIANGLE_VALUE_PLANE = 7 * PLANE_STATE_SIZE
+TRIANGLE_BEND_PLANE = 8 * PLANE_STATE_SIZE
+NOISE_CONTROL_PLANE = 9 * PLANE_STATE_SIZE
+NOISE_VALUE_PLANE = 10 * PLANE_STATE_SIZE
diff --git a/src/sampletones_player/driver/assembly/nsf.cfg b/src/sampletones_player/driver/assembly/nsf.cfg
new file mode 100644
index 000000000..f27d950a8
--- /dev/null
+++ b/src/sampletones_player/driver/assembly/nsf.cfg
@@ -0,0 +1,10 @@
+MEMORY {
+ ZP: start = $0000, size = $0100, type = rw, define = yes;
+ PRG: start = $8000, size = $8000, type = ro, define = yes, file = %O, fill = no;
+}
+
+SEGMENTS {
+ ZEROPAGE: load = ZP, type = zp;
+ CODE: load = PRG, type = ro;
+ SONG: load = PRG, type = ro, optional = yes;
+}
diff --git a/src/sampletones_player/driver/assembly/source/channels.s b/src/sampletones_player/driver/assembly/source/channels.s
new file mode 100644
index 000000000..4dd833e6c
--- /dev/null
+++ b/src/sampletones_player/driver/assembly/source/channels.s
@@ -0,0 +1,332 @@
+.setcpu "6502"
+
+.include "nes.inc"
+.include "song.inc"
+
+.export channels_reset
+.export channels_rewind
+.export channels_advance
+.export channels_write
+
+.import song_data
+
+SHADOW_UNWRITTEN = $FF
+
+.segment "ZEROPAGE"
+
+pointer: .res 2
+entry: .res 2
+opcode: .res 1
+phrase_table: .res 2
+timer_low_table: .res 2
+timer_high_table: .res 2
+bend: .res 1
+bend_sign: .res 1
+plane_state: .res PLANE_STATE_BYTES
+timer_high_shadows: .res TRIANGLE_REGISTERS + 1
+
+.segment "CODE"
+
+.assert OPCODE_SIZE = 1, error, "a token's operands follow its opcode by one byte"
+.assert PHRASE_TABLE_ENTRY_SIZE = 2, error, "a table entry is reached by one doubling"
+.assert PHRASE_LENGTH_SIZE = 1, error, "a phrase body follows its length by one byte"
+
+; Readies the tables the planes read through and points every plane at its own first token.
+channels_reset:
+ lda #SHADOW_UNWRITTEN
+ sta timer_high_shadows + PULSE1_REGISTERS
+ sta timer_high_shadows + PULSE2_REGISTERS
+ sta timer_high_shadows + TRIANGLE_REGISTERS
+
+ clc
+ lda song_data + TIMER_TABLE_OFFSET
+ adc #song_data
+ sta timer_low_table + 1
+
+ clc
+ lda timer_low_table
+ adc #PITCH_COUNT
+ sta timer_high_table
+ lda timer_low_table + 1
+ adc #$00
+ sta timer_high_table + 1
+
+ clc
+ lda song_data + PHRASE_TABLE_OFFSET
+ adc #<(song_data + PHRASE_TABLE_COUNT_SIZE)
+ sta phrase_table
+ lda song_data + PHRASE_TABLE_OFFSET + 1
+ adc #>(song_data + PHRASE_TABLE_COUNT_SIZE)
+ sta phrase_table + 1
+
+ lda #<(song_data + STREAM_OFFSETS_OFFSET)
+ sta pointer
+ lda #>(song_data + STREAM_OFFSETS_OFFSET)
+ sta pointer + 1
+ jmp seed_planes
+
+; Brings every plane back to the token its stream is re-entered at, which is the whole of what a
+; song coming round restores: the token the loop tick starts is one the plane reads outright.
+channels_rewind:
+ lda #<(song_data + LOOP_ENTRIES_OFFSET)
+ sta pointer
+ lda #>(song_data + LOOP_ENTRIES_OFFSET)
+ sta pointer + 1
+ jmp seed_planes
+
+; Points each plane at the offset the header holds for it at (pointer), and empties what it plays.
+seed_planes:
+ ldx #$00
+ ldy #$00
+@next:
+ clc
+ lda (pointer),y
+ adc #song_data
+ sta plane_state + PLANE_SOURCE + 1,x
+ iny
+
+ lda #$00
+ sta plane_state + PLANE_PHRASE_TICKS,x
+ sta plane_state + PLANE_TOKEN_TICKS,x
+ sta plane_state + PLANE_VALUE,x
+ sta plane_state + PLANE_SHIFT,x
+
+ txa
+ clc
+ adc #PLANE_STATE_SIZE
+ tax
+ cpx #PLANE_STATE_BYTES
+ bne @next
+ rts
+
+; Advances every plane by one tick of the song.
+channels_advance:
+ ldx #$00
+@next:
+ jsr plane_advance
+ txa
+ clc
+ adc #PLANE_STATE_SIZE
+ tax
+ cpx #PLANE_STATE_BYTES
+ bne @next
+ rts
+
+; Advances the plane whose state lies at X by one tick, leaving the value it plays in that state.
+; A token states the ticks it covers beyond the one it is fetched for, so a plane reaches for the
+; next token the moment the one it stands on has none left.
+;
+; The values a tick plays come from wherever the token put them: a phrase body, the bytes spelled
+; out behind a literal, or the value the plane already reached. A body played out holds its last
+; value onwards, which is what carries a note whose envelope has finished.
+plane_advance:
+ lda plane_state + PLANE_TOKEN_TICKS,x
+ bne @within
+ jsr fetch_token
+ jmp @plays
+@within:
+ dec plane_state + PLANE_TOKEN_TICKS,x
+@plays:
+ lda plane_state + PLANE_PHRASE_TICKS,x
+ beq @held
+ lda (plane_state + PLANE_PHRASE,x)
+ clc
+ adc plane_state + PLANE_SHIFT,x
+ sta plane_state + PLANE_VALUE,x
+ dec plane_state + PLANE_PHRASE_TICKS,x
+ beq @held
+ inc plane_state + PLANE_PHRASE,x
+ bne @held
+ inc plane_state + PLANE_PHRASE + 1,x
+@held:
+ rts
+
+; Reads the token the plane at X stands on into that plane's own state.
+fetch_token:
+ lda plane_state + PLANE_SOURCE,x
+ sta pointer
+ lda plane_state + PLANE_SOURCE + 1,x
+ sta pointer + 1
+
+ ldy #$00
+ lda (pointer),y
+ sta opcode
+ iny
+
+ and #TOKEN_TAG_MASK
+ beq @holds
+ cmp #TAG_LITERAL
+ beq @spells
+ jmp @plays_phrase
+
+@holds:
+ lda opcode
+ and #TOKEN_OPERAND_MASK
+ sta plane_state + PLANE_TOKEN_TICKS,x
+ lda #$00
+ sta plane_state + PLANE_PHRASE_TICKS,x
+ jmp advance_source
+
+@spells:
+ lda opcode
+ and #TOKEN_OPERAND_MASK
+ sta plane_state + PLANE_TOKEN_TICKS,x
+ clc
+ adc #$01
+ sta plane_state + PLANE_PHRASE_TICKS,x
+
+ lda #$00
+ sta plane_state + PLANE_SHIFT,x
+
+ clc
+ lda pointer
+ adc #OPCODE_SIZE
+ sta plane_state + PLANE_PHRASE,x
+ lda pointer + 1
+ adc #$00
+ sta plane_state + PLANE_PHRASE + 1,x
+
+ clc
+ tya
+ adc plane_state + PLANE_PHRASE_TICKS,x
+ tay
+ jmp advance_source
+
+@plays_phrase:
+ lda opcode
+ and #TOKEN_OPERAND_MASK
+ cmp #PHRASE_ID_ESCAPE
+ bne @named
+ lda (pointer),y
+ iny
+@named:
+ jsr set_phrase
+ lda (pointer),y
+ iny
+ sta plane_state + PLANE_TOKEN_TICKS,x
+ lda #$00
+ sta plane_state + PLANE_SHIFT,x
+ bit opcode
+ bvc advance_source
+ lda (pointer),y
+ iny
+ sta plane_state + PLANE_SHIFT,x
+
+; Moves the plane's source on by the Y bytes its token took.
+advance_source:
+ clc
+ tya
+ adc plane_state + PLANE_SOURCE,x
+ sta plane_state + PLANE_SOURCE,x
+ bcc @done
+ inc plane_state + PLANE_SOURCE + 1,x
+@done:
+ rts
+
+; Points the plane at X at the body of the phrase whose id lies in A, and states how many of that
+; body's own values are left to play.
+set_phrase:
+ asl
+ sta entry
+ lda #$00
+ rol
+ sta entry + 1
+
+ clc
+ lda entry
+ adc phrase_table
+ sta entry
+ lda entry + 1
+ adc phrase_table + 1
+ sta entry + 1
+
+ tya
+ pha
+ ldy #$00
+ clc
+ lda (entry),y
+ adc #song_data
+ sta plane_state + PLANE_PHRASE + 1,x
+
+ lda plane_state + PLANE_PHRASE,x
+ sta entry
+ lda plane_state + PLANE_PHRASE + 1,x
+ sta entry + 1
+ ldy #$00
+ lda (entry),y
+ sta plane_state + PLANE_PHRASE_TICKS,x
+
+ inc plane_state + PLANE_PHRASE,x
+ bne @body
+ inc plane_state + PLANE_PHRASE + 1,x
+@body:
+ pla
+ tay
+ rts
+
+; Writes what every plane last played to the registers its channel owns.
+channels_write:
+ lda plane_state + PULSE1_CONTROL_PLANE + PLANE_VALUE
+ sta CHANNEL_CONTROL + PULSE1_REGISTERS
+ ldx #PULSE1_REGISTERS
+ ldy plane_state + PULSE1_VALUE_PLANE + PLANE_VALUE
+ lda plane_state + PULSE1_BEND_PLANE + PLANE_VALUE
+ jsr write_timer
+
+ lda plane_state + PULSE2_CONTROL_PLANE + PLANE_VALUE
+ sta CHANNEL_CONTROL + PULSE2_REGISTERS
+ ldx #PULSE2_REGISTERS
+ ldy plane_state + PULSE2_VALUE_PLANE + PLANE_VALUE
+ lda plane_state + PULSE2_BEND_PLANE + PLANE_VALUE
+ jsr write_timer
+
+ lda plane_state + TRIANGLE_CONTROL_PLANE + PLANE_VALUE
+ sta CHANNEL_CONTROL + TRIANGLE_REGISTERS
+ ldx #TRIANGLE_REGISTERS
+ ldy plane_state + TRIANGLE_VALUE_PLANE + PLANE_VALUE
+ lda plane_state + TRIANGLE_BEND_PLANE + PLANE_VALUE
+ jsr write_timer
+
+ lda plane_state + NOISE_CONTROL_PLANE + PLANE_VALUE
+ sta CHANNEL_CONTROL + NOISE_REGISTERS
+ lda plane_state + NOISE_VALUE_PLANE + PLANE_VALUE
+ sta CHANNEL_TIMER_LOW + NOISE_REGISTERS
+ rts
+
+; Writes the timer the pitch at Y sounds at, moved by the bend in A, to the channel whose
+; register base lies in X. The bend states divider steps in two's complement, so it reaches the
+; timer's high half as $00 or $FF beside the carry the low half raised. A high half reaches the
+; register only where it differs from the last one written, since storing it restarts a pulse
+; waveform and reloads the triangle's counter.
+write_timer:
+ sta bend
+ lda #$00
+ bit bend
+ bpl @extended
+ lda #$FF
+@extended:
+ sta bend_sign
+
+ lda (timer_low_table),y
+ clc
+ adc bend
+ sta CHANNEL_TIMER_LOW,x
+ lda (timer_high_table),y
+ adc bend_sign
+ cmp timer_high_shadows,x
+ beq @held
+ sta timer_high_shadows,x
+ sta CHANNEL_TIMER_HIGH,x
+@held:
+ rts
diff --git a/src/sampletones_player/driver/assembly/source/clock.s b/src/sampletones_player/driver/assembly/source/clock.s
new file mode 100644
index 000000000..8e8f3abe6
--- /dev/null
+++ b/src/sampletones_player/driver/assembly/source/clock.s
@@ -0,0 +1,85 @@
+.setcpu "6502"
+
+.include "song.inc"
+
+.export clock_reset
+.export clock_advance
+.export clock_step
+
+.import song_data
+
+.segment "ZEROPAGE"
+
+accumulator: .res 2
+current_tick: .res 2
+finished: .res 1
+
+.segment "CODE"
+
+clock_reset:
+ lda #$00
+ sta accumulator
+ sta accumulator + 1
+ sta current_tick
+ sta current_tick + 1
+ sta finished
+ rts
+
+; Answers with the ticks this play call advances the streams by, which the accumulator reads off
+; the top of a step added once a call. A song standing still between its own ticks, and one that
+; has ended, both answer with none.
+clock_advance:
+ lda finished
+ bne @none
+
+ clc
+ lda accumulator
+ adc song_data + STEP_FRACTION_OFFSET
+ sta accumulator
+ lda accumulator + 1
+ adc song_data + STEP_FRACTION_OFFSET + 1
+ sta accumulator + 1
+ lda song_data + STEP_WHOLE_OFFSET
+ adc #$00
+ rts
+@none:
+ lda #$00
+ rts
+
+; Moves the clock on by a single tick and answers what the channels are to do with it: play it
+; where the song still runs, play it from the loop entry where the song has just come round, or
+; leave the console alone where the song has ended.
+clock_step:
+ inc current_tick
+ bne @reached
+ inc current_tick + 1
+@reached:
+ lda current_tick + 1
+ cmp song_data + TOTAL_TICKS_OFFSET + 1
+ bcc @plays
+ bne @ended
+ lda current_tick
+ cmp song_data + TOTAL_TICKS_OFFSET
+ bcc @plays
+@ended:
+ lda song_data + LOOP_TICK_OFFSET
+ cmp #NO_LOOP
+ bne @repeats
+
+ lda #$01
+ sta finished
+ lda #TICK_FINISHED
+ rts
+@repeats:
+ lda song_data + LOOP_TICK_OFFSET
+ sta current_tick
+ lda song_data + LOOP_TICK_OFFSET + 1
+ sta current_tick + 1
+ lda #TICK_REPEATED
+ rts
+@plays:
+ lda #TICK_PLAYS
+ rts
diff --git a/src/sampletones_player/driver/assembly/source/driver.s b/src/sampletones_player/driver/assembly/source/driver.s
new file mode 100644
index 000000000..775715dc0
--- /dev/null
+++ b/src/sampletones_player/driver/assembly/source/driver.s
@@ -0,0 +1,78 @@
+.setcpu "6502"
+
+.include "nes.inc"
+.include "song.inc"
+
+.export nsf_init
+.export nsf_play
+.export song_data
+
+.import clock_reset
+.import clock_advance
+.import clock_step
+.import channels_reset
+.import channels_rewind
+.import channels_advance
+.import channels_write
+
+.segment "ZEROPAGE"
+
+pending_ticks: .res 1
+
+.segment "CODE"
+
+nsf_init:
+ jmp start_song
+nsf_play:
+ jmp advance_song
+
+start_song:
+ jsr silence_channels
+ lda #CHANNELS_ENABLED
+ sta APU_STATUS
+ lda #FRAME_COUNTER_SEQUENCE
+ sta APU_FRAME_COUNTER
+ lda #SWEEP_DISABLED
+ sta PULSE1_SWEEP
+ sta PULSE2_SWEEP
+ lda #NOISE_LENGTH_COUNTER_LOAD
+ sta NOISE_LENGTH_COUNTER
+ jsr clock_reset
+ jsr channels_reset
+ jsr channels_advance
+ jmp channels_write
+
+; Advances the streams by the ticks this call is due and writes the tick they land on. A call
+; crossing the song's end either brings the planes back to where the song repeats and plays on,
+; or leaves the console holding what the final tick wrote.
+advance_song:
+ jsr clock_advance
+ beq @held
+ sta pending_ticks
+@next:
+ jsr clock_step
+ beq @held
+ cmp #TICK_REPEATED
+ bne @plays
+ jsr channels_rewind
+@plays:
+ jsr channels_advance
+ dec pending_ticks
+ bne @next
+ jmp channels_write
+@held:
+ rts
+
+silence_channels:
+ lda #SILENCED_REGISTER
+ ldx #$00
+@next:
+ sta FIRST_CHANNEL_REGISTER,x
+ inx
+ cpx #CHANNEL_REGISTER_COUNT
+ bne @next
+ rts
+
+.segment "SONG"
+
+song_data:
diff --git a/src/sampletones_player/driver/binary/driver.bin b/src/sampletones_player/driver/binary/driver.bin
new file mode 100644
index 000000000..3a1b2060b
Binary files /dev/null and b/src/sampletones_player/driver/binary/driver.bin differ
diff --git a/src/sampletones_player/driver/image.py b/src/sampletones_player/driver/image.py
new file mode 100644
index 000000000..f16292b57
--- /dev/null
+++ b/src/sampletones_player/driver/image.py
@@ -0,0 +1,81 @@
+from __future__ import annotations
+
+from importlib import resources
+
+from pydantic import BaseModel, ConfigDict, Field, model_validator
+
+from sampletones_player.driver.addresses import DriverAddresses
+from sampletones_player.specification.driver import (
+ DRIVER_BINARY_DIRECTORY,
+ DRIVER_CODE_NAME,
+ DRIVER_PACKAGE,
+ JUMP_ABSOLUTE_OPCODE,
+)
+
+
+class DriverImage(BaseModel):
+ """The assembled player, paired with the addresses it lays out.
+
+ The 6502 program that plays a song is written in assembly, built once by ``make player`` and
+ committed beside its sources, so exporting an NSF needs no assembler. Pairing the bytes with
+ their addresses is what lets the exporter name the routines in an NSF header and place the
+ song where the driver looks for it.
+
+ Attributes:
+ code: The assembled bytes, loaded at the image's load address.
+ addresses: Where the code and the song behind it sit.
+ """
+
+ model_config = ConfigDict(extra="forbid", frozen=True)
+
+ code: bytes = Field(..., min_length=1)
+ addresses: DriverAddresses
+
+ @model_validator(mode="after")
+ def _validate_the_song_follows_the_code(self) -> DriverImage:
+ expected = self.addresses.load + len(self.code)
+ if self.addresses.song != expected:
+ raise ValueError(
+ f"the song belongs at {expected:#06x}, directly behind {len(self.code)} bytes of "
+ f"code loaded at {self.addresses.load:#06x}, and the image states "
+ f"{self.addresses.song:#06x}"
+ )
+
+ return self
+
+ @model_validator(mode="after")
+ def _validate_the_routines_lie_in_the_code(self) -> DriverImage:
+ for name, address in (("init", self.addresses.init), ("play", self.addresses.play)):
+ if not self.addresses.load <= address < self.addresses.song:
+ raise ValueError(
+ f"the {name} routine lies at {address:#06x}, outside the code between "
+ f"{self.addresses.load:#06x} and {self.addresses.song:#06x}"
+ )
+
+ return self
+
+ @model_validator(mode="after")
+ def _validate_the_image_leads_with_its_entry_points(self) -> DriverImage:
+ for name, address in (("init", self.addresses.init), ("play", self.addresses.play)):
+ opcode = self.code[address - self.addresses.load]
+ if opcode != JUMP_ABSOLUTE_OPCODE:
+ raise ValueError(
+ f"the {name} routine answers at {address:#06x}, where the image holds "
+ f"{opcode:#04x} rather than the jump the entry points lead with"
+ )
+
+ return self
+
+ @classmethod
+ def load(cls) -> DriverImage:
+ """Reads the driver committed in the package.
+
+ Returns:
+ DriverImage: The assembled bytes and the addresses they lay out.
+
+ Raises:
+ ValueError: If the committed bytes lay out something other than the addresses the
+ driver is built to answer at.
+ """
+ code = (resources.files(DRIVER_PACKAGE) / DRIVER_BINARY_DIRECTORY / DRIVER_CODE_NAME).read_bytes()
+ return cls(code=code, addresses=DriverAddresses.for_code(len(code)))
diff --git a/src/sampletones_player/export.py b/src/sampletones_player/export.py
new file mode 100644
index 000000000..e2532a767
--- /dev/null
+++ b/src/sampletones_player/export.py
@@ -0,0 +1,228 @@
+from pathlib import Path
+from typing import Final, FrozenSet
+
+from sampletones_core.exports.artifact import ExportArtifact
+from sampletones_core.exports.format import ExportFormat
+from sampletones_core.exports.progress import (
+ ExportProgress,
+ ExportReporter,
+ announce,
+)
+from sampletones_core.exports.request import (
+ InstrumentExport,
+ ProjectExport,
+ SampleExport,
+)
+from sampletones_core.exports.scope import ExportScope
+from sampletones_core.exports.stage import ExportStage
+from sampletones_core.performance import WalkProgress, WalkReporter
+from sampletones_player.builder import SONG_START, song_from_project, song_from_sample
+from sampletones_player.compression.progress.report import CodecProgress, CodecReporter
+from sampletones_player.driver.image import DriverImage
+from sampletones_player.nsf.file import write_nsf
+from sampletones_player.nsf.information import NSFInformation
+from sampletones_shared.paths.extensions import EXT_FILE_NSF
+from sampletones_shared.utils.progress import silent_reporter
+
+SUPPORTED_SCOPES: FrozenSet[ExportScope] = frozenset(
+ {
+ ExportScope.INSTRUMENT,
+ ExportScope.SAMPLE,
+ ExportScope.PROJECT,
+ }
+)
+
+NO_ARTIST: Final[str] = ""
+WHOLE_ENVELOPE: None = None
+NOTHING_DONE: Final[int] = 0
+ONE_FILE: Final[int] = 1
+UNMEASURED: None = None
+
+
+def _walking(report: ExportReporter) -> WalkReporter:
+ """The walk's own reckoning, said in the words an export reports itself in.
+
+ A song's order states the ticks it lasts before a row of it is played, so this stage travels
+ toward a length it knows and reads as a true fraction of the song.
+
+ Args:
+ report: Hears each stage of the export, and answers whether it goes on.
+
+ Returns:
+ WalkReporter: What playing the song out tells the export about itself.
+ """
+
+ def reached(progress: WalkProgress) -> bool:
+ return report(
+ ExportProgress(
+ stage=ExportStage.WALKING,
+ completed=progress.ticks,
+ total=progress.total,
+ )
+ )
+
+ return reached
+
+
+def _compressing(report: ExportReporter) -> CodecReporter:
+ """The codec's own reckoning, said in the words an export reports itself in.
+
+ A codec run ends when no further phrase pays for itself, which the song decides rather than
+ the caller, so what it offers is the bytes it has laid down so far and no length to measure
+ them against.
+
+ Args:
+ report: Hears each stage of the export, and answers whether it goes on.
+
+ Returns:
+ CodecReporter: What the compression tells the export about itself.
+ """
+
+ def reached(progress: CodecProgress) -> bool:
+ return report(
+ ExportProgress(
+ stage=ExportStage.COMPRESSING,
+ completed=progress.size,
+ total=UNMEASURED,
+ )
+ )
+
+ return reached
+
+
+class NSFBackend:
+ """Writes the ``.nsf`` files NES sound players and the console itself play.
+
+ An NSF carries its own driver, so the file plays the reconstruction rather than describing
+ it to a program that does: every channel slice sounds at once on the channel it was
+ reconstructed for, at the rate it was built at and in the tuning it was built with. One file
+ holds one song, so a reconstruction and a single slice each become a program of their own,
+ the slice sounding on its channel alone.
+
+ The console's program area bounds how long a song may run, and one outgrowing it is reported
+ rather than written short.
+
+ Every file carries the same assembled driver, which the backend reads once as it is built.
+ A build shipping without it therefore reports itself where the backends are composed, and
+ an export spends its reads on the song alone.
+ """
+
+ def __init__(self) -> None:
+ """Reads the driver every written file carries.
+
+ Raises:
+ OSError: If the packaged driver is absent.
+ ValueError: If the packaged driver lays out something other than the addresses it
+ is built to answer at.
+ """
+ self._image = DriverImage.load()
+
+ @property
+ def export_format(self) -> ExportFormat:
+ return ExportFormat.NSF
+
+ @property
+ def supported_scopes(self) -> FrozenSet[ExportScope]:
+ return SUPPORTED_SCOPES
+
+ def extension(self, scope: ExportScope) -> str: # pylint: disable=unused-argument
+ return EXT_FILE_NSF
+
+ def write_instrument(
+ self,
+ destination: Path,
+ request: InstrumentExport,
+ report: ExportReporter = silent_reporter,
+ ) -> ExportArtifact:
+ """Writes a program playing one channel slice.
+
+ Raises:
+ OperationCanceled: If ``report`` withdraws the write.
+ SongTooLargeError: If the slice runs longer than the program area holds.
+ OSError: If the destination cannot be written.
+ """
+ sample = SampleExport(
+ name=request.name,
+ instruments=(request,),
+ nes_frequency=request.nes_frequency,
+ tuning=request.tuning,
+ )
+ return self.write_sample(destination, sample, report)
+
+ def write_sample(
+ self,
+ destination: Path,
+ request: SampleExport,
+ report: ExportReporter = silent_reporter,
+ ) -> ExportArtifact:
+ """Writes a program playing every channel slice of one reconstruction together.
+
+ The slices are sounded out tick by tick, the ticks are compressed to what the console
+ has room for, and the file is written; each of those says so as it starts, so a run of
+ seconds reads as the work it is doing.
+
+ Raises:
+ OperationCanceled: If ``report`` withdraws the write.
+ SongTooLargeError: If the reconstruction runs longer than the program area holds.
+ OSError: If the destination cannot be written.
+ """
+ destination.parent.mkdir(parents=True, exist_ok=True)
+ announce(report, ExportStage.WALKING, NOTHING_DONE, UNMEASURED)
+ song = song_from_sample(request, _compressing(report))
+
+ announce(report, ExportStage.WRITING, NOTHING_DONE, ONE_FILE)
+ write_nsf(
+ destination,
+ song,
+ NSFInformation(
+ title=request.name,
+ artist=NO_ARTIST,
+ ),
+ self._image,
+ )
+ announce(report, ExportStage.WRITING, ONE_FILE, ONE_FILE)
+
+ return ExportArtifact(paths=(destination,), truncation=WHOLE_ENVELOPE)
+
+ def write_project(
+ self,
+ destination: Path,
+ request: ProjectExport,
+ report: ExportReporter = silent_reporter,
+ ) -> ExportArtifact:
+ """Writes a program playing a whole composition.
+
+ The arrangement is played out row by row into the ticks each channel sounds, those ticks
+ are compressed to what the console has room for, and the file is written; each of those
+ says so as it starts, and the walk reads as a fraction of the song it is playing out.
+
+ The file repeats from its first tick, which is how a piece of music is listened to and
+ what an NSF player expects of a song that has reached its end.
+
+ Raises:
+ OperationCanceled: If ``report`` withdraws the write.
+ SongTooLargeError: If the song holds more than the program area has room for.
+ OSError: If the destination cannot be written.
+ ValueError: If the project's samples were reconstructed against tunings that differ.
+ """
+ destination.parent.mkdir(parents=True, exist_ok=True)
+ song = song_from_project(
+ request.project,
+ SONG_START,
+ _compressing(report),
+ _walking(report),
+ )
+
+ announce(report, ExportStage.WRITING, NOTHING_DONE, ONE_FILE)
+ write_nsf(
+ destination,
+ song,
+ NSFInformation(
+ title=request.project.info.title,
+ artist=request.project.info.author,
+ ),
+ self._image,
+ )
+ announce(report, ExportStage.WRITING, ONE_FILE, ONE_FILE)
+
+ return ExportArtifact(paths=(destination,), truncation=WHOLE_ENVELOPE)
diff --git a/src/sampletones_player/nsf/__init__.py b/src/sampletones_player/nsf/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/src/sampletones_player/nsf/file.py b/src/sampletones_player/nsf/file.py
new file mode 100644
index 000000000..f63b2b71c
--- /dev/null
+++ b/src/sampletones_player/nsf/file.py
@@ -0,0 +1,56 @@
+from sampletones_player.driver.image import DriverImage
+from sampletones_player.nsf.header import header_to_bytes
+from sampletones_player.nsf.information import NSFInformation
+from sampletones_player.nsf.song import song_to_bytes
+from sampletones_player.song import Song
+from sampletones_player.specification.nsf import PROGRAM_SIZE
+from sampletones_shared.types.path import Pathlike
+from sampletones_shared.utils.serialization import save_binary
+
+
+def nsf_to_bytes(
+ song: Song,
+ information: NSFInformation,
+ image: DriverImage,
+) -> bytes:
+ """Builds the bytes of a playable NSF: the header, the driver and the song it plays.
+
+ The three parts sit in the order the console loads them, the song following the driver at
+ the address the image reports, so the space the song has to fit in is what the program area
+ leaves behind the code.
+
+ Args:
+ song: The streams, the clock and the loop point to play.
+ information: The text fields the file is listed under.
+ image: The assembled driver the file carries, and the addresses its routines answer at.
+
+ Returns:
+ bytes: The whole file.
+
+ Raises:
+ SongTooLargeError: If the song takes more room than the driver leaves it.
+ """
+ data = song_to_bytes(song, PROGRAM_SIZE - len(image.code))
+
+ return header_to_bytes(information, image.addresses) + image.code + data
+
+
+def write_nsf(
+ filepath: Pathlike,
+ song: Song,
+ information: NSFInformation,
+ image: DriverImage,
+) -> None:
+ """Exports a song to a playable ``.nsf`` file.
+
+ Args:
+ filepath: The file to write.
+ song: The streams, the clock and the loop point to play.
+ information: The text fields the file is listed under.
+ image: The assembled driver the file carries, and the addresses its routines answer at.
+
+ Raises:
+ SongTooLargeError: If the song takes more room than the driver leaves it.
+ OSError: If the destination cannot be written.
+ """
+ save_binary(filepath, nsf_to_bytes(song, information, image))
diff --git a/src/sampletones_player/nsf/header.py b/src/sampletones_player/nsf/header.py
new file mode 100644
index 000000000..60645d751
--- /dev/null
+++ b/src/sampletones_player/nsf/header.py
@@ -0,0 +1,72 @@
+from sampletones_core.formats.binary import BinaryWriter
+from sampletones_player.driver.addresses import DriverAddresses
+from sampletones_player.nsf.information import NSFInformation
+from sampletones_player.specification.nsf import (
+ FIRST_SONG,
+ NO_BANKSWITCHING,
+ NO_EXPANSION_CHIPS,
+ NO_NSF2_FEATURES,
+ NSF2_LENGTH_UNSTATED,
+ NSF_MAGIC,
+ NSF_VERSION,
+ NTSC_PLAY_PERIOD_MICROSECONDS,
+ NTSC_REGION,
+ PAL_PLAY_PERIOD_MICROSECONDS,
+ SONG_COUNT,
+ STRING_FIELD_SIZE,
+)
+
+
+def _write_identity(writer: BinaryWriter) -> None:
+ writer.write_bytes(NSF_MAGIC)
+ writer.write_uint8(NSF_VERSION)
+ writer.write_uint8(SONG_COUNT)
+ writer.write_uint8(FIRST_SONG)
+
+
+def _write_routines(writer: BinaryWriter, addresses: DriverAddresses) -> None:
+ writer.write_uint16(addresses.load)
+ writer.write_uint16(addresses.init)
+ writer.write_uint16(addresses.play)
+
+
+def _write_strings(writer: BinaryWriter, information: NSFInformation) -> None:
+ writer.write_fixed_string(information.title, STRING_FIELD_SIZE)
+ writer.write_fixed_string(information.artist, STRING_FIELD_SIZE)
+ writer.write_fixed_string(information.copyright, STRING_FIELD_SIZE)
+
+
+def _write_playback(writer: BinaryWriter) -> None:
+ writer.write_uint16(NTSC_PLAY_PERIOD_MICROSECONDS)
+ writer.write_bytes(NO_BANKSWITCHING)
+ writer.write_uint16(PAL_PLAY_PERIOD_MICROSECONDS)
+ writer.write_uint8(NTSC_REGION)
+ writer.write_uint8(NO_EXPANSION_CHIPS)
+ writer.write_uint8(NO_NSF2_FEATURES)
+ writer.write_bytes(NSF2_LENGTH_UNSTATED)
+
+
+def header_to_bytes(
+ information: NSFInformation,
+ addresses: DriverAddresses,
+) -> bytes:
+ """Serializes the 128-byte header a console's NSF player reads a file through.
+
+ The header states where the image loads and which routines start and drive it, so the whole
+ of what the console needs to run the driver is named here. The song is one tune played on
+ the 2A03 alone, loaded whole at a fixed address and driven at the NTSC rate the schedule
+ counts in.
+
+ Args:
+ information: The text fields the file is listed under.
+ addresses: Where the driver loads and which addresses its routines answer at.
+
+ Returns:
+ bytes: The header, ready for the image and the song to follow it.
+ """
+ writer = BinaryWriter()
+ _write_identity(writer)
+ _write_routines(writer, addresses)
+ _write_strings(writer, information)
+ _write_playback(writer)
+ return writer.data
diff --git a/src/sampletones_player/nsf/information.py b/src/sampletones_player/nsf/information.py
new file mode 100644
index 000000000..782b8a909
--- /dev/null
+++ b/src/sampletones_player/nsf/information.py
@@ -0,0 +1,22 @@
+from pydantic import BaseModel, ConfigDict
+
+from sampletones_shared.application import SAMPLETONES_COPYRIGHT
+
+
+class NSFInformation(BaseModel):
+ """The three text fields an NSF header carries, shown by the players that read them.
+
+ Each field reaches the file as a fixed 32-byte string, so text longer than the field holds
+ is written as much of itself as fits.
+
+ Attributes:
+ title: Name the song is listed under.
+ artist: Who the song is credited to.
+ copyright: Who holds the rights to it.
+ """
+
+ model_config = ConfigDict(extra="forbid", frozen=True)
+
+ title: str
+ artist: str
+ copyright: str = SAMPLETONES_COPYRIGHT
diff --git a/src/sampletones_player/nsf/layout.py b/src/sampletones_player/nsf/layout.py
new file mode 100644
index 000000000..70ae9baca
--- /dev/null
+++ b/src/sampletones_player/nsf/layout.py
@@ -0,0 +1,109 @@
+from __future__ import annotations
+
+from dataclasses import dataclass
+from typing import Final, Sequence, Tuple
+
+from sampletones_player.compression.dictionary.table import PhraseTable
+from sampletones_player.compression.planes.order import PlaneOrder
+from sampletones_player.song import Song
+from sampletones_player.specification.compression import (
+ PHRASE_LENGTH_SIZE,
+ PHRASE_TABLE_COUNT_SIZE,
+ PHRASE_TABLE_ENTRY_SIZE,
+)
+from sampletones_player.specification.song import SONG_HEADER_SIZE
+
+FIRST_TICK: Final[int] = 0
+NAME_SEPARATOR: Final[str] = "_"
+
+
+def _running(start: int, sizes: Sequence[int]) -> Tuple[int, ...]:
+ offsets = []
+ offset = start
+ for size in sizes:
+ offsets.append(offset)
+ offset += size
+
+ return tuple(offsets)
+
+
+@dataclass(frozen=True)
+class SongLayout:
+ """Where each part of a song block begins, every offset counted from the block's first byte.
+
+ The block is written and read through these offsets alone, which is what lets each part grow
+ with the song it carries: a header states where the tables and the streams lie, a phrase
+ entry states where its body lies, and the driver reaches any of them by adding the offset to
+ the address the song loaded at.
+
+ Attributes:
+ timer_table: Where the timer each pitch sounds at begins.
+ phrase_table: Where the dictionary's count and entries begin.
+ bodies: Where each phrase's length byte lies, in id order.
+ streams: Where each plane's token stream begins, in song-block order.
+ loop_entries: Where each plane's stream is re-entered once the song repeats.
+ size: The bytes the whole block takes.
+ """
+
+ timer_table: int
+ phrase_table: int
+ bodies: Tuple[int, ...]
+ streams: Tuple[int, ...]
+ loop_entries: Tuple[int, ...]
+ size: int
+
+ @classmethod
+ def of(cls, song: Song) -> SongLayout:
+ """Lays a song out into the block the driver plays it from.
+
+ Args:
+ song: The compressed planes, the timer table and the clock to lay out.
+
+ Returns:
+ SongLayout: Where each part of the block begins.
+
+ Raises:
+ ValueError: If a stream spans the song's loop tick rather than starting a token there.
+ """
+ phrases = song.planes.phrases
+ streams = song.planes.streams
+ body_sizes = [PHRASE_LENGTH_SIZE + phrase.length for phrase in phrases.phrases]
+ stream_sizes = [len(stream) for stream in streams]
+
+ timer_table = SONG_HEADER_SIZE
+ phrase_table = timer_table + len(song.pitches.data)
+ bodies = phrase_table + cls._table_size(phrases)
+ stream_start = bodies + sum(body_sizes)
+
+ stream_offsets = _running(stream_start, stream_sizes)
+ entered = song.planes.entries(FIRST_TICK if song.loop_tick is None else song.loop_tick)
+ return cls(
+ timer_table=timer_table,
+ phrase_table=phrase_table,
+ bodies=_running(bodies, body_sizes),
+ streams=stream_offsets,
+ loop_entries=tuple(offset + entry for offset, entry in zip(stream_offsets, entered)),
+ size=stream_start + sum(stream_sizes),
+ )
+
+ @staticmethod
+ def _table_size(phrases: PhraseTable) -> int:
+ return PHRASE_TABLE_COUNT_SIZE + PHRASE_TABLE_ENTRY_SIZE * len(phrases)
+
+ @property
+ def stated(self) -> Tuple[Tuple[str, int], ...]:
+ """Every offset the block states, each under the name of what it points at."""
+ return (
+ ("timer table", self.timer_table),
+ ("phrase table", self.phrase_table),
+ *((f"phrase {phrase_id}", offset) for phrase_id, offset in enumerate(self.bodies)),
+ *self._named(self.streams, "stream"),
+ *self._named(self.loop_entries, "loop entry"),
+ )
+
+ @staticmethod
+ def _named(offsets: Sequence[int], part: str) -> Tuple[Tuple[str, int], ...]:
+ return tuple(
+ (f"{plane.replace(NAME_SEPARATOR, ' ')} {part}", offset)
+ for plane, offset in zip(PlaneOrder.names(), offsets)
+ )
diff --git a/src/sampletones_player/nsf/song.py b/src/sampletones_player/nsf/song.py
new file mode 100644
index 000000000..ba76470fc
--- /dev/null
+++ b/src/sampletones_player/nsf/song.py
@@ -0,0 +1,96 @@
+from sampletones_core.formats.binary import BinaryWriter
+from sampletones_player.compression.dictionary.table import PhraseTable
+from sampletones_player.compression.pitch import PitchTable
+from sampletones_player.compression.planes.order import PlaneOrder
+from sampletones_player.nsf.layout import SongLayout
+from sampletones_player.song import Song
+from sampletones_player.specification.song import MAX_BLOCK_OFFSET, NO_LOOP
+from sampletones_shared.exceptions import SongTooLargeError
+
+
+def _write_header(
+ writer: BinaryWriter,
+ song: Song,
+ layout: SongLayout,
+) -> None:
+ step = song.schedule.fixed_point_step
+ writer.write_uint8(step.whole)
+ writer.write_uint16(step.fraction)
+ writer.write_uint16(song.ticks)
+ writer.write_uint16(NO_LOOP if song.loop_tick is None else song.loop_tick)
+ writer.write_uint16(layout.timer_table)
+ writer.write_uint16(layout.phrase_table)
+ for offset in layout.streams:
+ writer.write_uint16(offset)
+
+ for offset in layout.loop_entries:
+ writer.write_uint16(offset)
+
+
+def _write_timer_table(writer: BinaryWriter, pitches: PitchTable) -> None:
+ writer.write_bytes(pitches.data)
+
+
+def _write_phrase_table(
+ writer: BinaryWriter,
+ phrases: PhraseTable,
+ layout: SongLayout,
+) -> None:
+ writer.write_uint8(len(phrases))
+ for offset in layout.bodies:
+ writer.write_uint16(offset)
+
+ for phrase in phrases.phrases:
+ writer.write_uint8(phrase.length)
+ writer.write_bytes(phrase.body)
+
+
+def _write_streams(writer: BinaryWriter, streams: PlaneOrder) -> None:
+ for stream in streams:
+ writer.write_bytes(stream)
+
+
+def _validate_space(size: int, available_bytes: int) -> None:
+ if size > available_bytes:
+ raise SongTooLargeError(f"the song takes {size} bytes and {available_bytes} are free")
+
+
+def _validate_offsets(layout: SongLayout) -> None:
+ for part, offset in layout.stated:
+ if offset > MAX_BLOCK_OFFSET:
+ raise SongTooLargeError(
+ f"the {part} lies {offset} bytes into the song " f"and its header states at most {MAX_BLOCK_OFFSET}",
+ )
+
+
+def song_to_bytes(song: Song, available_bytes: int) -> bytes:
+ """Serializes a song to the bytes the driver reads it from.
+
+ The header states the clock, the length and where each of the song's parts begins, so the
+ whole block plays from wherever the file loads it: the timer every pitch sounds at, the
+ dictionary the tokens name, and one token stream per plane, which the channels decode a tick at a
+ time. A song that repeats also states the byte each stream is re-entered at, which is the
+ whole of what a loop restores.
+
+ Args:
+ song: The compressed planes, the timer table, the clock and the loop point to write.
+ available_bytes: The space the song has to fit in.
+
+ Returns:
+ bytes: The song header, the timer table, the dictionary and every plane's token stream.
+
+ Raises:
+ SongTooLargeError: If the song takes more than ``available_bytes``, or reaches further
+ into itself than an offset it states.
+ ValueError: If a stream spans the song's loop tick rather than starting a token there.
+ """
+ layout = SongLayout.of(song)
+ _validate_space(layout.size, available_bytes)
+ _validate_offsets(layout)
+
+ writer = BinaryWriter()
+ _write_header(writer, song, layout)
+ _write_timer_table(writer, song.pitches)
+ _write_phrase_table(writer, song.planes.phrases, layout)
+ _write_streams(writer, song.planes.streams)
+ return writer.data
diff --git a/src/sampletones_player/registers/__init__.py b/src/sampletones_player/registers/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/src/sampletones_player/registers/base.py b/src/sampletones_player/registers/base.py
new file mode 100644
index 000000000..140f6249a
--- /dev/null
+++ b/src/sampletones_player/registers/base.py
@@ -0,0 +1,24 @@
+from abc import ABC, abstractmethod
+from typing import Tuple
+
+from pydantic import BaseModel, ConfigDict
+
+
+class ChannelRegisters(BaseModel, ABC):
+ """The register values one channel writes for a single engine tick.
+
+ The driver interprets nothing: it moves these bytes to the addresses its channel owns.
+ Every rule the hardware follows — how a duty cycle reaches its bits, which value silences
+ a channel, how a pitch becomes a period — is settled here, where it is testable.
+ """
+
+ model_config = ConfigDict(extra="forbid", frozen=True)
+
+ @property
+ @abstractmethod
+ def values(self) -> Tuple[int, ...]:
+ """The tick's register values, in the order the driver writes them.
+
+ Returns:
+ Tuple[int, ...]: One value per register the channel writes each tick.
+ """
diff --git a/src/sampletones_player/registers/channel.py b/src/sampletones_player/registers/channel.py
new file mode 100644
index 000000000..cc9bb2586
--- /dev/null
+++ b/src/sampletones_player/registers/channel.py
@@ -0,0 +1,123 @@
+from typing import Dict, List, Literal, Mapping, Sequence, Tuple, Type, overload
+
+from sampletones_core.constants.enums import ChannelName
+from sampletones_core.instructions import (
+ InstructionT,
+ InstructionUnion,
+ NoiseInstruction,
+ PulseInstruction,
+ TriangleInstruction,
+)
+from sampletones_player.registers.base import ChannelRegisters
+from sampletones_player.registers.noise import NoiseRegisters
+from sampletones_player.registers.playable import playable
+from sampletones_player.registers.pulse import PulseRegisters
+from sampletones_player.registers.triangle import TriangleRegisters
+
+
+def channel_instructions(
+ instructions: Sequence[InstructionUnion],
+ instruction_type: Type[InstructionT],
+) -> List[InstructionT]:
+ """One channel's stream, read as the instruction type that channel sounds.
+
+ A reconstruction holds a stream for every channel, and a channel standing by holds one
+ describing no frame. Such a channel reaches the player resting for a single tick, which is
+ the shortest stream a song lays its records out from. Each frame arrives as the driver can
+ sound it — see :func:`playable`.
+
+ Args:
+ instructions: The channel's stream, as the reconstruction holds it.
+ instruction_type: The instruction type the channel's encoder reads.
+
+ Returns:
+ List[InstructionT]: The stream, covering at least one tick.
+
+ Raises:
+ TypeError: If the stream holds an instruction another channel sounds.
+ """
+ typed: List[InstructionT] = []
+ for instruction in instructions:
+ if not isinstance(instruction, instruction_type):
+ raise TypeError(
+ f"a {instruction_type.__name__} stream holds {type(instruction).__name__} "
+ f"{instruction.name}, which another channel sounds"
+ )
+
+ sounded = playable(instruction)
+ assert isinstance(sounded, instruction_type)
+ typed.append(sounded)
+
+ if typed:
+ return typed
+
+ resting: InstructionT = instruction_type.null_instruction()
+ return [resting]
+
+
+@overload
+def channel_registers(
+ channel: Literal[ChannelName.PULSE1, ChannelName.PULSE2],
+ instructions: Mapping[ChannelName, Sequence[InstructionUnion]],
+ timer_table: Dict[int, int],
+) -> Tuple[PulseRegisters, ...]: ...
+
+
+@overload
+def channel_registers(
+ channel: Literal[ChannelName.TRIANGLE],
+ instructions: Mapping[ChannelName, Sequence[InstructionUnion]],
+ timer_table: Dict[int, int],
+) -> Tuple[TriangleRegisters, ...]: ...
+
+
+@overload
+def channel_registers(
+ channel: Literal[ChannelName.NOISE],
+ instructions: Mapping[ChannelName, Sequence[InstructionUnion]],
+ timer_table: Dict[int, int],
+) -> Tuple[NoiseRegisters, ...]: ...
+
+
+@overload
+def channel_registers(
+ channel: ChannelName,
+ instructions: Mapping[ChannelName, Sequence[InstructionUnion]],
+ timer_table: Dict[int, int],
+) -> Tuple[ChannelRegisters, ...]: ...
+
+
+def channel_registers(
+ channel: ChannelName,
+ instructions: Mapping[ChannelName, Sequence[InstructionUnion]],
+ timer_table: Dict[int, int],
+) -> Tuple[ChannelRegisters, ...]:
+ """Encodes one channel's instructions into the register values its ticks write.
+
+ The channel decides all three halves of the answer: which stream of the song it plays, the
+ instruction type that stream is read as, and the register set those instructions become.
+ Naming the channel is therefore the whole of what a caller states, and a channel the song
+ leaves out rests through it.
+
+ Args:
+ channel: The channel to encode.
+ instructions: The per-tick instructions each channel carries.
+ timer_table: The timer register value each pitch sounds at.
+
+ Returns:
+ Tuple[ChannelRegisters, ...]: One register set per tick, covering at least one tick.
+
+ Raises:
+ TypeError: If the channel's stream holds an instruction another channel sounds.
+ """
+ played = instructions.get(channel, ())
+ match channel:
+ case ChannelName.PULSE1 | ChannelName.PULSE2:
+ pulse = channel_instructions(played, PulseInstruction)
+ return tuple(PulseRegisters.from_instructions(pulse, timer_table))
+ case ChannelName.TRIANGLE:
+ triangle = channel_instructions(played, TriangleInstruction)
+ return tuple(TriangleRegisters.from_instructions(triangle, timer_table))
+ case ChannelName.NOISE:
+ noise = channel_instructions(played, NoiseInstruction)
+ return tuple(NoiseRegisters.from_instructions(noise))
diff --git a/src/sampletones_player/registers/hold.py b/src/sampletones_player/registers/hold.py
new file mode 100644
index 000000000..1372396c0
--- /dev/null
+++ b/src/sampletones_player/registers/hold.py
@@ -0,0 +1,22 @@
+from typing import Sequence, TypeVar
+
+HeldValue = TypeVar("HeldValue")
+
+
+def hold(values: Sequence[HeldValue], index: int) -> HeldValue:
+ """Reads a held stream at a tick, sustaining its final value past the stream's end.
+
+ An exporter states a channel's pitch and timbre for every tick its instructions cover, and
+ appends one silent tick past them so a sample ends quiet. That release tick reads the values
+ the channel was holding when it stopped sounding, and the same rule carries a channel that
+ runs out early through the rest of a song.
+
+ Args:
+ values: The held stream, covering at least one tick.
+ index: The tick to read.
+
+ Returns:
+ HeldValue: The value at that tick, or the stream's final value once the index reaches
+ its end.
+ """
+ return values[min(index, len(values) - 1)]
diff --git a/src/sampletones_player/registers/noise.py b/src/sampletones_player/registers/noise.py
new file mode 100644
index 000000000..dc4b4b1eb
--- /dev/null
+++ b/src/sampletones_player/registers/noise.py
@@ -0,0 +1,54 @@
+from __future__ import annotations
+
+from typing import List, Tuple
+
+from pydantic import Field
+
+from sampletones_core.constants.general import MAX_PERIOD
+from sampletones_core.exporters.implementation.noise import NoiseExporter
+from sampletones_core.instructions import NoiseInstruction
+from sampletones_player.registers.base import ChannelRegisters
+from sampletones_player.registers.hold import hold
+from sampletones_player.specification.registers import (
+ MAX_REGISTER_VALUE,
+ NOISE_MODE_SHIFT,
+ SUSTAINED_LEVEL,
+)
+
+
+class NoiseRegisters(ChannelRegisters):
+ control: int = Field(..., ge=0, le=MAX_REGISTER_VALUE)
+ period: int = Field(..., ge=0, le=MAX_REGISTER_VALUE)
+
+ @property
+ def values(self) -> Tuple[int, ...]:
+ return (self.control, self.period)
+
+ @classmethod
+ def from_instructions(cls, instructions: List[NoiseInstruction]) -> List[NoiseRegisters]:
+ """Turns a noise channel's instructions into the registers each tick writes.
+
+ The project counts noise periods from the slowest, and the register counts them from the
+ fastest, so a period reaches the register as its complement. The mode bit rides above it,
+ selecting the shift register's short 93-step cycle.
+
+ Args:
+ instructions: The channel's per-tick instructions.
+
+ Returns:
+ List[NoiseRegisters]: One register set per tick, including the closing release tick.
+ """
+ _, periods, volumes, modes = NoiseExporter.extract_data(instructions)
+
+ registers: List[NoiseRegisters] = []
+ for index, volume in enumerate(volumes):
+ period = hold(periods, index)
+ mode = hold(modes, index)
+ registers.append(
+ cls(
+ control=SUSTAINED_LEVEL | volume,
+ period=(mode << NOISE_MODE_SHIFT) | (MAX_PERIOD - period),
+ )
+ )
+
+ return registers
diff --git a/src/sampletones_player/registers/playable.py b/src/sampletones_player/registers/playable.py
new file mode 100644
index 000000000..3a1632f9f
--- /dev/null
+++ b/src/sampletones_player/registers/playable.py
@@ -0,0 +1,36 @@
+from sampletones_core.instructions import (
+ InstructionUnion,
+ PulseInstruction,
+ TriangleInstruction,
+)
+
+NO_BEND: int = 0
+
+
+def playable(instruction: InstructionUnion) -> InstructionUnion:
+ """One frame as the driver sounds it, which is at the note it names.
+
+ A channel's plane states a pitch as its distance above the lowest note the song reaches, and
+ that is what lets a phrase be transposed by adding to it — a divider offset added to an index
+ means nothing there. A frame carrying a bend therefore sounds at its note's own divider until
+ the planes gain a place to put one; ``docs/development/bugs-and-todos.md`` under **Tracker**
+ owns that work.
+
+ Stating it here is what makes the loss deliberate and single-placed: the encoders below read
+ frames that carry no bend, and whoever holds the console's output against a reconstruction
+ reads the same answer.
+
+ Args:
+ instruction: The frame as the reconstruction holds it.
+
+ Returns:
+ InstructionUnion: The frame the driver can sound.
+ """
+ match instruction:
+ case PulseInstruction() | TriangleInstruction():
+ if not instruction.bent:
+ return instruction
+
+ return instruction.model_copy(update={"detune": NO_BEND, "coarse_detune": NO_BEND})
+ case _:
+ return instruction
diff --git a/src/sampletones_player/registers/pulse.py b/src/sampletones_player/registers/pulse.py
new file mode 100644
index 000000000..f0ca0a5a3
--- /dev/null
+++ b/src/sampletones_player/registers/pulse.py
@@ -0,0 +1,63 @@
+from __future__ import annotations
+
+from typing import Dict, List, Tuple
+
+from pydantic import Field
+
+from sampletones_core.exporters.implementation.pulse import PulseExporter
+from sampletones_core.instructions import PulseInstruction
+from sampletones_player.registers.base import ChannelRegisters
+from sampletones_player.registers.hold import hold
+from sampletones_player.specification.registers import (
+ DUTY_CYCLE_SHIFT,
+ MAX_REGISTER_VALUE,
+ MAX_TIMER_HIGH,
+ SUSTAINED_LEVEL,
+ TIMER_HIGH_SHIFT,
+)
+
+
+class PulseRegisters(ChannelRegisters):
+ control: int = Field(..., ge=0, le=MAX_REGISTER_VALUE)
+ timer_low: int = Field(..., ge=0, le=MAX_REGISTER_VALUE)
+ timer_high: int = Field(..., ge=0, le=MAX_TIMER_HIGH)
+
+ @property
+ def values(self) -> Tuple[int, ...]:
+ return (self.control, self.timer_low, self.timer_high)
+
+ @classmethod
+ def from_instructions(
+ cls,
+ instructions: List[PulseInstruction],
+ timer_table: Dict[int, int],
+ ) -> List[PulseRegisters]:
+ """Turns a pulse channel's instructions into the registers each tick writes.
+
+ Volume rides in the control byte's low nibble, so a rest keeps its pitch and duty cycle
+ and sets the level to zero. Holding the period across a rest is what lets the driver leave
+ the timer's high byte alone, and leaving it alone is what keeps the waveform's phase
+ running the way a rendered channel does.
+
+ Args:
+ instructions: The channel's per-tick instructions.
+ timer_table: The timer register value each pitch sounds at.
+
+ Returns:
+ List[PulseRegisters]: One register set per tick, including the closing release tick.
+ """
+ _, pitches, volumes, duty_cycles = PulseExporter.extract_data(instructions)
+
+ registers: List[PulseRegisters] = []
+ for index, volume in enumerate(volumes):
+ timer = timer_table[hold(pitches, index)]
+ duty_cycle = hold(duty_cycles, index)
+ registers.append(
+ cls(
+ control=(duty_cycle << DUTY_CYCLE_SHIFT) | SUSTAINED_LEVEL | volume,
+ timer_low=timer & MAX_REGISTER_VALUE,
+ timer_high=timer >> TIMER_HIGH_SHIFT,
+ )
+ )
+
+ return registers
diff --git a/src/sampletones_player/registers/streams.py b/src/sampletones_player/registers/streams.py
new file mode 100644
index 000000000..89ca2ea55
--- /dev/null
+++ b/src/sampletones_player/registers/streams.py
@@ -0,0 +1,74 @@
+from __future__ import annotations
+
+from typing import Tuple
+
+from pydantic import BaseModel, ConfigDict, model_validator
+
+from sampletones_core.constants.enums import ChannelName
+from sampletones_player.registers.base import ChannelRegisters
+from sampletones_player.registers.hold import hold
+from sampletones_player.registers.noise import NoiseRegisters
+from sampletones_player.registers.pulse import PulseRegisters
+from sampletones_player.registers.triangle import TriangleRegisters
+
+
+class ChannelStreams(BaseModel):
+ """The per-tick register values of all four channels, together the whole of what a song plays.
+
+ A channel's stream ends where its instructions do, and an exporter closes a channel that was
+ still sounding with one silent tick, so the four streams reach the same tick give or take that
+ one. The song therefore lasts as long as its longest channel, and a channel that runs out
+ first holds its final values — silent ones, every stream ending on a rest — through the ticks
+ that remain.
+
+ Attributes:
+ pulse1: The first pulse channel's stream.
+ pulse2: The second pulse channel's stream.
+ triangle: The triangle channel's stream.
+ noise: The noise channel's stream.
+ """
+
+ model_config = ConfigDict(extra="forbid", frozen=True)
+
+ pulse1: Tuple[PulseRegisters, ...]
+ pulse2: Tuple[PulseRegisters, ...]
+ triangle: Tuple[TriangleRegisters, ...]
+ noise: Tuple[NoiseRegisters, ...]
+
+ @model_validator(mode="after")
+ def _validate_every_channel_reaches_a_tick(self) -> ChannelStreams:
+ empty = tuple(channel.value for channel, stream in zip(ChannelName.items(), self.ordered) if not stream)
+ if empty:
+ raise ValueError(f"every channel needs at least one tick, and {', '.join(empty)} has none")
+
+ return self
+
+ @property
+ def ordered(self) -> Tuple[Tuple[ChannelRegisters, ...], ...]:
+ """The four streams in the order the generator names run."""
+ return (self.pulse1, self.pulse2, self.triangle, self.noise)
+
+ @property
+ def ticks(self) -> int:
+ """The ticks the song lasts, the longest channel stating the length."""
+ return max(len(stream) for stream in self.ordered)
+
+ @property
+ def padded(self) -> Tuple[Tuple[ChannelRegisters, ...], ...]:
+ """The four streams each carried to the song's full length, ready to serialize.
+
+ Every channel reaching the same tick count is what lets the driver read a record by
+ multiplying the tick by the channel's record size.
+ """
+ return tuple(tuple(hold(stream, tick) for tick in range(self.ticks)) for stream in self.ordered)
+
+ def at(self, tick: int) -> Tuple[ChannelRegisters, ...]:
+ """Each channel's registers at ``tick``, a channel past its end holding its final values.
+
+ Args:
+ tick: The tick to read, counted from 0.
+
+ Returns:
+ Tuple[ChannelRegisters, ...]: One register set per channel, in channel order.
+ """
+ return tuple(hold(stream, tick) for stream in self.ordered)
diff --git a/src/sampletones_player/registers/triangle.py b/src/sampletones_player/registers/triangle.py
new file mode 100644
index 000000000..88a3a3724
--- /dev/null
+++ b/src/sampletones_player/registers/triangle.py
@@ -0,0 +1,67 @@
+from __future__ import annotations
+
+from typing import Dict, List, Tuple
+
+from pydantic import Field
+
+from sampletones_core.exporters.implementation.triangle import TriangleExporter
+from sampletones_core.instructions import TriangleInstruction
+from sampletones_player.registers.base import ChannelRegisters
+from sampletones_player.registers.hold import hold
+from sampletones_player.specification.registers import (
+ MAX_REGISTER_VALUE,
+ MAX_TIMER_HIGH,
+ TIMER_HIGH_SHIFT,
+ TRIANGLE_COUNTER_CONTROL,
+ TRIANGLE_SILENT_RELOAD,
+ TRIANGLE_SOUNDING_RELOAD,
+)
+
+
+class TriangleRegisters(ChannelRegisters):
+ linear_counter: int = Field(..., ge=0, le=MAX_REGISTER_VALUE)
+ timer_low: int = Field(..., ge=0, le=MAX_REGISTER_VALUE)
+ timer_high: int = Field(..., ge=0, le=MAX_TIMER_HIGH)
+
+ @property
+ def values(self) -> Tuple[int, ...]:
+ return (self.linear_counter, self.timer_low, self.timer_high)
+
+ @classmethod
+ def from_instructions(
+ cls,
+ instructions: List[TriangleInstruction],
+ timer_table: Dict[int, int],
+ ) -> List[TriangleRegisters]:
+ """Turns a triangle channel's instructions into the registers each tick writes.
+
+ The triangle sounds at one level, so a tick states whether it sounds through the linear
+ counter's reload value: a full reload keeps the waveform running, and a reload of zero
+ holds it silent. The control bit stays set throughout, which is what makes the counter
+ reload every frame and the note last as long as the ticks do.
+
+ The timer is written from the instruction's pitch directly, and the channel sounds an
+ octave below it — the same octave a rendered triangle sounds.
+
+ Args:
+ instructions: The channel's per-tick instructions.
+ timer_table: The timer register value each pitch sounds at.
+
+ Returns:
+ List[TriangleRegisters]: One register set per tick, including the closing release tick.
+ """
+ _, pitches, volumes = TriangleExporter.extract_data(instructions)
+
+ registers: List[TriangleRegisters] = []
+ for index, volume in enumerate(volumes):
+ timer = timer_table[hold(pitches, index)]
+ reload_value = TRIANGLE_SOUNDING_RELOAD if volume > 0 else TRIANGLE_SILENT_RELOAD
+ registers.append(
+ cls(
+ linear_counter=TRIANGLE_COUNTER_CONTROL | reload_value,
+ timer_low=timer & MAX_REGISTER_VALUE,
+ timer_high=timer >> TIMER_HIGH_SHIFT,
+ )
+ )
+
+ return registers
diff --git a/src/sampletones_player/song.py b/src/sampletones_player/song.py
new file mode 100644
index 000000000..705872bcb
--- /dev/null
+++ b/src/sampletones_player/song.py
@@ -0,0 +1,124 @@
+from __future__ import annotations
+
+from functools import cached_property
+from typing import Optional, Sequence
+
+from pydantic import BaseModel, ConfigDict, model_validator
+
+from sampletones_player.clock.schedule import PlaySchedule
+from sampletones_player.compression.compressed import CompressedPlanes
+from sampletones_player.compression.dictionary.phrase import Phrase
+from sampletones_player.compression.pitch import PitchTable
+from sampletones_player.compression.progress.report import CodecReporter
+from sampletones_player.compression.song import compress_song, decompress_song
+from sampletones_player.registers.streams import ChannelStreams
+from sampletones_shared.utils.progress import silent_reporter
+
+
+class Song(BaseModel):
+ """A song as the console holds it: compressed channel planes, a timer table and a clock.
+
+ A file carries a song as one token stream per plane over one dictionary, and this is that song, so
+ what the player holds and what the console reads are the same value. The register values each
+ channel writes are read back out of the streams, the timer table turning a plane's pitch index
+ into the divider the hardware takes.
+
+ Attributes:
+ planes: The dictionary and the token stream every plane plays.
+ pitches: The timer each pitch sounds at.
+ schedule: The engine ticks each play call advances the streams by.
+ loop_tick: The tick the song returns to once it ends, or ``None`` where it stops there.
+ """
+
+ model_config = ConfigDict(extra="forbid", frozen=True)
+
+ planes: CompressedPlanes
+ pitches: PitchTable
+ schedule: PlaySchedule
+ loop_tick: Optional[int]
+
+ @classmethod
+ def from_streams(
+ cls,
+ streams: ChannelStreams,
+ pitches: PitchTable,
+ *,
+ schedule: PlaySchedule,
+ loop_tick: Optional[int],
+ seeds: Sequence[Phrase],
+ report: CodecReporter = silent_reporter,
+ ) -> Song:
+ """Compresses the register values a song plays into the song the console holds.
+
+ Args:
+ streams: The per-tick register values every channel plays.
+ pitches: The timer each pitch sounds at.
+ schedule: The engine ticks each play call advances the streams by.
+ loop_tick: The tick the song returns to once it ends, or ``None`` where it stops
+ there.
+ seeds: The phrases the song's instruments offer the dictionary.
+ report: Hears what the codec holds each time it looks up, and answers whether the
+ compression goes on.
+
+ Returns:
+ Song: The song as the console holds it.
+
+ Raises:
+ OperationCanceled: If ``report`` withdraws the compression.
+ ValueError: If ``loop_tick`` lies outside the song's ticks, or a channel sounds a
+ timer the pitch table states no index for.
+ """
+ return cls(
+ planes=compress_song(
+ streams,
+ pitches,
+ seeds=seeds,
+ loop_tick=loop_tick,
+ report=report,
+ ),
+ pitches=pitches,
+ schedule=schedule,
+ loop_tick=loop_tick,
+ )
+
+ @model_validator(mode="after")
+ def _validate_the_loop_lies_within_the_song(self) -> Song:
+ if self.loop_tick is not None and not 0 <= self.loop_tick < self.ticks:
+ raise ValueError(f"loop_tick must lie within the song's {self.ticks} ticks, got {self.loop_tick}")
+
+ return self
+
+ @property
+ def ticks(self) -> int:
+ """The ticks the song lasts."""
+ return self.planes.ticks
+
+ @cached_property
+ def streams(self) -> ChannelStreams:
+ """The per-tick register values every channel plays, read back out of the token streams.
+
+ Every channel is carried to the song's full length, so one whose own instructions ran out
+ first holds the silent values it stopped on through the ticks that remain.
+ """
+ return decompress_song(self.planes, self.pitches)
+
+ def tick_at(self, play_call: int) -> Optional[int]:
+ """The tick the call at ``play_call`` leaves the streams on.
+
+ A song that runs past its end either returns to its loop tick and keeps going, or stops
+ and leaves the channels holding the silent values its final tick wrote.
+
+ Args:
+ play_call: The call's position in the run, counted from 0.
+
+ Returns:
+ Optional[int]: The tick to play, or ``None`` once a song without a loop has ended.
+ """
+ tick = self.schedule.ticks_at(play_call + 1)
+ if tick < self.ticks:
+ return tick
+
+ if self.loop_tick is None:
+ return None
+
+ return self.loop_tick + (tick - self.ticks) % (self.ticks - self.loop_tick)
diff --git a/src/sampletones_player/specification/__init__.py b/src/sampletones_player/specification/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/src/sampletones_player/specification/binary.py b/src/sampletones_player/specification/binary.py
new file mode 100644
index 000000000..917fe89ff
--- /dev/null
+++ b/src/sampletones_player/specification/binary.py
@@ -0,0 +1,39 @@
+from typing import Final
+
+WORD_SIZE: Final[int] = 2
+BYTE_VALUES: Final[int] = 256
+MAX_BYTE_VALUE: Final[int] = BYTE_VALUES - 1
+SIGNED_BYTE_LIMIT: Final[int] = BYTE_VALUES // 2
+
+
+def signed_byte(value: int) -> int:
+ """The number a byte states in two's complement, which is how a plane holds a signed value.
+
+ Args:
+ value: The byte as the stream holds it.
+
+ Returns:
+ int: The number it states.
+ """
+ if value < SIGNED_BYTE_LIMIT:
+ return value
+
+ return value - BYTE_VALUES
+
+
+def unsigned_byte(value: int) -> int:
+ """The byte a signed number reaches a stream as, which is the form a phrase shifts within.
+
+ Args:
+ value: The number to hold.
+
+ Returns:
+ int: The byte stating it.
+
+ Raises:
+ ValueError: If the number lies outside the range one byte states.
+ """
+ if not -SIGNED_BYTE_LIMIT <= value < SIGNED_BYTE_LIMIT:
+ raise ValueError(f"a byte states {-SIGNED_BYTE_LIMIT} through {SIGNED_BYTE_LIMIT - 1}, and this is {value}")
+
+ return value % BYTE_VALUES
diff --git a/src/sampletones_player/specification/channels.py b/src/sampletones_player/specification/channels.py
new file mode 100644
index 000000000..bcbeaa53d
--- /dev/null
+++ b/src/sampletones_player/specification/channels.py
@@ -0,0 +1,31 @@
+from typing import Dict, Final, FrozenSet, Tuple
+
+from sampletones_core.constants.enums import ChannelName
+from sampletones_player.specification.registers import (
+ NOISE_CONTROL,
+ NOISE_PERIOD,
+ PULSE1_CONTROL,
+ PULSE1_TIMER_HIGH,
+ PULSE1_TIMER_LOW,
+ PULSE2_CONTROL,
+ PULSE2_TIMER_HIGH,
+ PULSE2_TIMER_LOW,
+ TRIANGLE_LINEAR_COUNTER,
+ TRIANGLE_TIMER_HIGH,
+ TRIANGLE_TIMER_LOW,
+)
+
+CHANNEL_REGISTER_ADDRESSES: Final[Dict[ChannelName, Tuple[int, ...]]] = {
+ ChannelName.PULSE1: (PULSE1_CONTROL, PULSE1_TIMER_LOW, PULSE1_TIMER_HIGH),
+ ChannelName.PULSE2: (PULSE2_CONTROL, PULSE2_TIMER_LOW, PULSE2_TIMER_HIGH),
+ ChannelName.TRIANGLE: (TRIANGLE_LINEAR_COUNTER, TRIANGLE_TIMER_LOW, TRIANGLE_TIMER_HIGH),
+ ChannelName.NOISE: (NOISE_CONTROL, NOISE_PERIOD),
+}
+
+TONE_CHANNELS: Final[FrozenSet[ChannelName]] = frozenset(
+ {
+ ChannelName.PULSE1,
+ ChannelName.PULSE2,
+ ChannelName.TRIANGLE,
+ }
+)
diff --git a/src/sampletones_player/specification/clock.py b/src/sampletones_player/specification/clock.py
new file mode 100644
index 000000000..4d69fcd99
--- /dev/null
+++ b/src/sampletones_player/specification/clock.py
@@ -0,0 +1,13 @@
+from fractions import Fraction
+from typing import Final
+
+NTSC_MASTER_CYCLES_PER_FRAME: Final[int] = 357_366
+NTSC_MASTER_CLOCK_HERTZ: Final[Fraction] = Fraction(236_250_000, 11)
+NTSC_FRAME_RATE: Final[Fraction] = NTSC_MASTER_CLOCK_HERTZ / NTSC_MASTER_CYCLES_PER_FRAME
+
+MICROSECONDS_PER_SECOND: Final[int] = 1_000_000
+
+FIXED_POINT_BITS: Final[int] = 16
+FIXED_POINT_SCALE: Final[int] = 1 << FIXED_POINT_BITS
+MAX_STEP_FRACTION: Final[int] = FIXED_POINT_SCALE - 1
+MAX_STEP_WHOLE: Final[int] = 0xFF
diff --git a/src/sampletones_player/specification/compression.py b/src/sampletones_player/specification/compression.py
new file mode 100644
index 000000000..ea582df37
--- /dev/null
+++ b/src/sampletones_player/specification/compression.py
@@ -0,0 +1,59 @@
+from enum import IntEnum
+from math import ceil
+from typing import Final
+
+from sampletones_core.constants.enums import ChannelName
+from sampletones_player.specification.binary import (
+ BYTE_VALUES,
+ MAX_BYTE_VALUE,
+ WORD_SIZE,
+)
+from sampletones_player.specification.channels import TONE_CHANNELS
+from sampletones_shared.constants.general import BITS_PER_BYTE
+
+
+class TokenTag(IntEnum):
+ """What a token's opcode byte says its top two bits.
+
+ Attributes:
+ HOLD: The plane keeps the value it reached, for the ticks the operand counts.
+ LITERAL: The plane takes the bytes that follow, one per tick.
+ PHRASE: The plane plays a phrase from the table at the pitch it was stored at.
+ TRANSPOSED_PHRASE: The plane plays a phrase shifted by the signed byte that follows.
+ """
+
+ HOLD = 0x00
+ LITERAL = 0x40
+ PHRASE = 0x80
+ TRANSPOSED_PHRASE = 0xC0
+
+
+TOKEN_TAG_MASK: Final[int] = 0xC0
+TOKEN_OPERAND_MASK: Final[int] = 0x3F
+
+OPCODE_SIZE: Final[int] = 1
+PHRASE_COUNT_SIZE: Final[int] = 1
+PHRASE_ESCAPE_SIZE: Final[int] = 1
+TRANSPOSE_SIZE: Final[int] = 1
+
+MAX_HOLD_TICKS: Final[int] = TOKEN_OPERAND_MASK + 1
+MAX_LITERAL_BYTES: Final[int] = TOKEN_OPERAND_MASK + 1
+MAX_PHRASE_TICKS: Final[int] = BYTE_VALUES
+
+PHRASE_ID_ESCAPE: Final[int] = TOKEN_OPERAND_MASK
+CHEAP_PHRASE_IDS: Final[int] = PHRASE_ID_ESCAPE
+MAX_PHRASE_IDS: Final[int] = MAX_BYTE_VALUE
+MAX_PHRASE_LENGTH: Final[int] = MAX_BYTE_VALUE
+
+PHRASE_TABLE_COUNT_SIZE: Final[int] = ceil(MAX_PHRASE_IDS.bit_length() / BITS_PER_BYTE)
+PHRASE_TABLE_ENTRY_SIZE: Final[int] = WORD_SIZE
+PHRASE_LENGTH_SIZE: Final[int] = 1
+
+INITIAL_PLANE_VALUE: Final[int] = 0
+
+PLANES_PER_CHANNEL: Final[int] = 2
+TONE_PLANES_PER_CHANNEL: Final[int] = PLANES_PER_CHANNEL + 1
+PLANE_COUNT: Final[int] = sum(
+ TONE_PLANES_PER_CHANNEL if channel in TONE_CHANNELS else PLANES_PER_CHANNEL for channel in ChannelName.items()
+)
+PLANE_STATE_SIZE: Final[int] = 8
diff --git a/src/sampletones_player/specification/driver.py b/src/sampletones_player/specification/driver.py
new file mode 100644
index 000000000..a68a8593b
--- /dev/null
+++ b/src/sampletones_player/specification/driver.py
@@ -0,0 +1,15 @@
+from typing import Final
+
+from sampletones_player.specification.nsf import PROGRAM_START
+
+DRIVER_PACKAGE: Final[str] = "sampletones_player.driver"
+DRIVER_BINARY_DIRECTORY: Final[str] = "binary"
+DRIVER_CODE_NAME: Final[str] = "driver.bin"
+
+MAX_ADDRESS: Final[int] = 0xFFFF
+
+JUMP_ABSOLUTE_OPCODE: Final[int] = 0x4C
+JUMP_INSTRUCTION_SIZE: Final[int] = 3
+LOAD_ADDRESS: Final[int] = PROGRAM_START
+INIT_ADDRESS: Final[int] = LOAD_ADDRESS
+PLAY_ADDRESS: Final[int] = LOAD_ADDRESS + JUMP_INSTRUCTION_SIZE
diff --git a/src/sampletones_player/specification/nsf.py b/src/sampletones_player/specification/nsf.py
new file mode 100644
index 000000000..bc87e6198
--- /dev/null
+++ b/src/sampletones_player/specification/nsf.py
@@ -0,0 +1,46 @@
+from typing import Final
+
+from sampletones_player.specification.binary import WORD_SIZE
+from sampletones_player.specification.clock import (
+ MICROSECONDS_PER_SECOND,
+ NTSC_FRAME_RATE,
+)
+
+PROGRAM_START: Final[int] = 0x8000
+PROGRAM_SIZE: Final[int] = 0x8000
+
+NSF_MAGIC: Final[bytes] = b"NESM\x1a"
+NSF_VERSION: Final[int] = 1
+SONG_COUNT: Final[int] = 1
+FIRST_SONG: Final[int] = 1
+
+STRING_FIELD_SIZE: Final[int] = 32
+BANKSWITCH_SIZE: Final[int] = 8
+NSF2_LENGTH_SIZE: Final[int] = 3
+
+MAGIC_OFFSET: Final[int] = 0
+VERSION_OFFSET: Final[int] = MAGIC_OFFSET + len(NSF_MAGIC)
+SONG_COUNT_OFFSET: Final[int] = VERSION_OFFSET + 1
+FIRST_SONG_OFFSET: Final[int] = SONG_COUNT_OFFSET + 1
+LOAD_ADDRESS_OFFSET: Final[int] = FIRST_SONG_OFFSET + 1
+INIT_ADDRESS_OFFSET: Final[int] = LOAD_ADDRESS_OFFSET + WORD_SIZE
+PLAY_ADDRESS_OFFSET: Final[int] = INIT_ADDRESS_OFFSET + WORD_SIZE
+TITLE_OFFSET: Final[int] = PLAY_ADDRESS_OFFSET + WORD_SIZE
+ARTIST_OFFSET: Final[int] = TITLE_OFFSET + STRING_FIELD_SIZE
+COPYRIGHT_OFFSET: Final[int] = ARTIST_OFFSET + STRING_FIELD_SIZE
+NTSC_PERIOD_OFFSET: Final[int] = COPYRIGHT_OFFSET + STRING_FIELD_SIZE
+BANKSWITCH_OFFSET: Final[int] = NTSC_PERIOD_OFFSET + WORD_SIZE
+PAL_PERIOD_OFFSET: Final[int] = BANKSWITCH_OFFSET + BANKSWITCH_SIZE
+REGION_OFFSET: Final[int] = PAL_PERIOD_OFFSET + WORD_SIZE
+EXPANSION_OFFSET: Final[int] = REGION_OFFSET + 1
+NSF2_FEATURES_OFFSET: Final[int] = EXPANSION_OFFSET + 1
+NSF2_LENGTH_OFFSET: Final[int] = NSF2_FEATURES_OFFSET + 1
+HEADER_SIZE: Final[int] = NSF2_LENGTH_OFFSET + NSF2_LENGTH_SIZE
+
+NTSC_PLAY_PERIOD_MICROSECONDS: Final[int] = round(MICROSECONDS_PER_SECOND / NTSC_FRAME_RATE)
+PAL_PLAY_PERIOD_MICROSECONDS: Final[int] = 20000
+NTSC_REGION: Final[int] = 0x00
+NO_EXPANSION_CHIPS: Final[int] = 0x00
+NO_NSF2_FEATURES: Final[int] = 0x00
+NO_BANKSWITCHING: Final[bytes] = bytes(BANKSWITCH_SIZE)
+NSF2_LENGTH_UNSTATED: Final[bytes] = bytes(NSF2_LENGTH_SIZE)
diff --git a/src/sampletones_player/specification/registers.py b/src/sampletones_player/specification/registers.py
new file mode 100644
index 000000000..e44e09c75
--- /dev/null
+++ b/src/sampletones_player/specification/registers.py
@@ -0,0 +1,52 @@
+from typing import Final, FrozenSet
+
+from sampletones_core.constants.general import MAX_TIMER
+
+PULSE1_CONTROL: Final[int] = 0x4000
+PULSE1_SWEEP: Final[int] = 0x4001
+PULSE1_TIMER_LOW: Final[int] = 0x4002
+PULSE1_TIMER_HIGH: Final[int] = 0x4003
+PULSE2_CONTROL: Final[int] = 0x4004
+PULSE2_SWEEP: Final[int] = 0x4005
+PULSE2_TIMER_LOW: Final[int] = 0x4006
+PULSE2_TIMER_HIGH: Final[int] = 0x4007
+TRIANGLE_LINEAR_COUNTER: Final[int] = 0x4008
+TRIANGLE_TIMER_LOW: Final[int] = 0x400A
+TRIANGLE_TIMER_HIGH: Final[int] = 0x400B
+NOISE_CONTROL: Final[int] = 0x400C
+NOISE_PERIOD: Final[int] = 0x400E
+NOISE_LENGTH_COUNTER: Final[int] = 0x400F
+APU_STATUS: Final[int] = 0x4015
+APU_FRAME_COUNTER: Final[int] = 0x4017
+
+MAX_REGISTER_VALUE: Final[int] = 0xFF
+TIMER_HIGH_SHIFT: Final[int] = 8
+MAX_TIMER_HIGH: Final[int] = MAX_TIMER >> TIMER_HIGH_SHIFT
+
+LENGTH_COUNTER_HALT: Final[int] = 0x20
+CONSTANT_VOLUME: Final[int] = 0x10
+SUSTAINED_LEVEL: Final[int] = LENGTH_COUNTER_HALT | CONSTANT_VOLUME
+DUTY_CYCLE_SHIFT: Final[int] = 6
+SWEEP_DISABLED: Final[int] = 0x08
+
+TRIANGLE_COUNTER_CONTROL: Final[int] = 0x80
+TRIANGLE_SOUNDING_RELOAD: Final[int] = 0x7F
+TRIANGLE_SILENT_RELOAD: Final[int] = 0x00
+
+NOISE_MODE_SHIFT: Final[int] = 7
+NOISE_LENGTH_COUNTER_LOAD: Final[int] = 0x00
+
+CHANNELS_ENABLED: Final[int] = 0x0F
+FRAME_COUNTER_SEQUENCE: Final[int] = 0x40
+
+FIRST_CHANNEL_REGISTER: Final[int] = 0x4000
+LAST_CHANNEL_REGISTER: Final[int] = 0x4013
+SILENCED_REGISTER: Final[int] = 0x00
+
+REGISTERS_WRITTEN_ON_CHANGE: Final[FrozenSet[int]] = frozenset(
+ {
+ PULSE1_TIMER_HIGH,
+ PULSE2_TIMER_HIGH,
+ TRIANGLE_TIMER_HIGH,
+ }
+)
diff --git a/src/sampletones_player/specification/song.py b/src/sampletones_player/specification/song.py
new file mode 100644
index 000000000..ea9f706a2
--- /dev/null
+++ b/src/sampletones_player/specification/song.py
@@ -0,0 +1,17 @@
+from typing import Final
+
+from sampletones_player.specification.binary import WORD_SIZE
+from sampletones_player.specification.compression import PLANE_COUNT
+
+STEP_WHOLE_OFFSET: Final[int] = 0
+STEP_FRACTION_OFFSET: Final[int] = STEP_WHOLE_OFFSET + 1
+TOTAL_TICKS_OFFSET: Final[int] = STEP_FRACTION_OFFSET + WORD_SIZE
+LOOP_TICK_OFFSET: Final[int] = TOTAL_TICKS_OFFSET + WORD_SIZE
+TIMER_TABLE_OFFSET: Final[int] = LOOP_TICK_OFFSET + WORD_SIZE
+PHRASE_TABLE_OFFSET: Final[int] = TIMER_TABLE_OFFSET + WORD_SIZE
+STREAM_OFFSETS_OFFSET: Final[int] = PHRASE_TABLE_OFFSET + WORD_SIZE
+LOOP_ENTRIES_OFFSET: Final[int] = STREAM_OFFSETS_OFFSET + WORD_SIZE * PLANE_COUNT
+SONG_HEADER_SIZE: Final[int] = LOOP_ENTRIES_OFFSET + WORD_SIZE * PLANE_COUNT
+
+NO_LOOP: Final[int] = 0xFFFF
+MAX_BLOCK_OFFSET: Final[int] = 0xFFFF
diff --git a/src/sampletones_player/trace/__init__.py b/src/sampletones_player/trace/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/src/sampletones_player/trace/trace.py b/src/sampletones_player/trace/trace.py
new file mode 100644
index 000000000..be9779270
--- /dev/null
+++ b/src/sampletones_player/trace/trace.py
@@ -0,0 +1,120 @@
+from __future__ import annotations
+
+from dataclasses import dataclass
+from typing import Dict, Final, List, Tuple
+
+from sampletones_core.constants.enums import ChannelName
+from sampletones_player.song import Song
+from sampletones_player.specification.channels import CHANNEL_REGISTER_ADDRESSES
+from sampletones_player.specification.registers import (
+ APU_FRAME_COUNTER,
+ APU_STATUS,
+ CHANNELS_ENABLED,
+ FIRST_CHANNEL_REGISTER,
+ FRAME_COUNTER_SEQUENCE,
+ LAST_CHANNEL_REGISTER,
+ NOISE_LENGTH_COUNTER,
+ NOISE_LENGTH_COUNTER_LOAD,
+ PULSE1_SWEEP,
+ PULSE2_SWEEP,
+ REGISTERS_WRITTEN_ON_CHANGE,
+ SILENCED_REGISTER,
+ SWEEP_DISABLED,
+)
+from sampletones_player.trace.write import RegisterWrite
+
+FIRST_TICK: Final[int] = 0
+
+
+@dataclass(frozen=True)
+class RegisterTrace:
+ """Every APU register write a run of the driver makes, grouped by the call that makes it.
+
+ This is the contract the assembly is written against: initialization clears the channels,
+ enables them and sounds the song's first tick, and each play call afterwards either advances
+ the streams and writes the tick it lands on, or leaves the console alone. The three registers
+ that reset a running channel are written only where their value changes, which is what keeps
+ a pulse waveform's phase running across a rest the way a rendered channel does.
+
+ A channel sounds only while its length counter stands above zero, and the counter loads from a
+ write to the register carrying the length index once the channel is enabled. Initialization
+ therefore reaches those registers after :data:`APU_STATUS`: the noise channel's directly, and
+ the three that carry a timer through the first tick's high byte. Halting every counter is what
+ holds them there for the rest of the song.
+
+ Attributes:
+ initialization: The writes the init routine makes, leaving the console on the song's
+ first tick.
+ play_calls: The writes each play call makes, one entry per call, and an empty one for a
+ call the streams hold their tick through.
+ """
+
+ initialization: Tuple[RegisterWrite, ...]
+ play_calls: Tuple[Tuple[RegisterWrite, ...], ...]
+
+ @staticmethod
+ def _tick_writes(
+ song: Song,
+ tick: int,
+ shadows: Dict[int, int],
+ ) -> Tuple[RegisterWrite, ...]:
+ writes: List[RegisterWrite] = []
+ for channel, registers in zip(ChannelName.items(), song.streams.at(tick)):
+ for address, value in zip(CHANNEL_REGISTER_ADDRESSES[channel], registers.values):
+ if address in REGISTERS_WRITTEN_ON_CHANGE:
+ if shadows.get(address) == value:
+ continue
+
+ shadows[address] = value
+
+ writes.append(RegisterWrite(address, value))
+
+ return tuple(writes)
+
+ @classmethod
+ def _initialization_writes(cls, song: Song, shadows: Dict[int, int]) -> Tuple[RegisterWrite, ...]:
+ writes = [
+ RegisterWrite(address, SILENCED_REGISTER)
+ for address in range(FIRST_CHANNEL_REGISTER, LAST_CHANNEL_REGISTER + 1)
+ ]
+ writes.append(RegisterWrite(APU_STATUS, CHANNELS_ENABLED))
+ writes.append(RegisterWrite(APU_FRAME_COUNTER, FRAME_COUNTER_SEQUENCE))
+ writes.append(RegisterWrite(PULSE1_SWEEP, SWEEP_DISABLED))
+ writes.append(RegisterWrite(PULSE2_SWEEP, SWEEP_DISABLED))
+ writes.append(RegisterWrite(NOISE_LENGTH_COUNTER, NOISE_LENGTH_COUNTER_LOAD))
+ writes.extend(cls._tick_writes(song, FIRST_TICK, shadows))
+ return tuple(writes)
+
+ @classmethod
+ def from_song(cls, song: Song, play_calls: int) -> RegisterTrace:
+ """States every APU write a correct driver makes over a run of play calls.
+
+ Args:
+ song: The streams, the clock and the loop point the driver plays.
+ play_calls: How many play calls the run covers, at least 0.
+
+ Returns:
+ RegisterTrace: The initialization writes and the writes of every call in the run.
+
+ Raises:
+ ValueError: If ``play_calls`` is negative.
+ """
+ if play_calls < 0:
+ raise ValueError(f"play_calls must be at least 0, got {play_calls}")
+
+ shadows: Dict[int, int] = {}
+ initialization = cls._initialization_writes(song, shadows)
+
+ calls: List[Tuple[RegisterWrite, ...]] = []
+ for play_call in range(play_calls):
+ tick = song.tick_at(play_call)
+ if tick is None or song.schedule.advance_at(play_call) == 0:
+ calls.append(())
+ continue
+
+ calls.append(cls._tick_writes(song, tick, shadows))
+
+ return cls(
+ initialization=initialization,
+ play_calls=tuple(calls),
+ )
diff --git a/src/sampletones_player/trace/write.py b/src/sampletones_player/trace/write.py
new file mode 100644
index 000000000..ef07da300
--- /dev/null
+++ b/src/sampletones_player/trace/write.py
@@ -0,0 +1,13 @@
+from typing import NamedTuple
+
+
+class RegisterWrite(NamedTuple):
+ """One store the driver makes to an APU register.
+
+ Attributes:
+ address: The register the store lands on.
+ value: The byte it carries.
+ """
+
+ address: int
+ value: int
diff --git a/src/sampletones_shared/application.py b/src/sampletones_shared/application.py
index cf5222e64..e6eb24b80 100644
--- a/src/sampletones_shared/application.py
+++ b/src/sampletones_shared/application.py
@@ -7,9 +7,11 @@
SAMPLETONES_VERSION: Final[str] = metadata.version(SAMPLETONES_PACKAGE_NAME)
SAMPLETONES_LIBRARY_DATA_VERSION: Final[str] = "2.0"
-SAMPLETONES_RECONSTRUCTION_DATA_VERSION: Final[str] = "2.1"
-SAMPLETONES_PROJECT_DATA_VERSION: Final[str] = "1.0"
+SAMPLETONES_RECONSTRUCTION_DATA_VERSION: Final[str] = "2.2"
+SAMPLETONES_PROJECT_DATA_VERSION: Final[str] = "1.1"
SAMPLETONES_NAME_VERSION: Final[str] = f"{SAMPLETONES_NAME} v{SAMPLETONES_VERSION}"
SAMPLETONES_AUTHOR: Final[str] = "Jakim"
SAMPLETONES_GROUP: Final[str] = "Stage Magician"
+
+SAMPLETONES_COPYRIGHT: Final[str] = f"generated by {SAMPLETONES_NAME}"
diff --git a/src/sampletones_shared/array.py b/src/sampletones_shared/array.py
index 006271a4b..7b7dd1234 100644
--- a/src/sampletones_shared/array.py
+++ b/src/sampletones_shared/array.py
@@ -1,9 +1,13 @@
-from typing import Optional, Type, Union
+import warnings
+from typing import Final, Optional, Type, Union
import numpy as np
+from sampletones_shared.exceptions import CuPyNotInstalledWarning
from sampletones_shared.logger import logger
+CUPY_MISSING_MESSAGE: Final[str] = "CuPy is not available, falling back to NumPy."
+
def _preload_cuda_libraries() -> None:
"""Make CUDA libraries shipped as ``nvidia-*-cu12`` wheels discoverable by CuPy.
@@ -32,35 +36,44 @@ def _preload_cuda_libraries() -> None:
pass
+def _format_warning_no_location(
+ message: Union[Warning, str],
+ category: Type[Warning],
+ filename: str, # pylint: disable=unused-argument
+ lineno: int, # pylint: disable=unused-argument
+ line: Optional[str] = None, # pylint: disable=unused-argument
+) -> str:
+ return f"{category.__name__}: {message}\n"
+
+
CUPY_AVAILABLE = False # pylint: disable=invalid-name
try:
_preload_cuda_libraries()
import cupy as xp
import cupy.typing as xp_typing
- CUPY_AVAILABLE = True # pylint: disable=invalid-name,
- logger.info(f"CuPy {xp.__version__} is active")
+ CUPY_AVAILABLE = True # pylint: disable=invalid-name
except (AttributeError, ImportError, ModuleNotFoundError):
- import warnings
+ import numpy.typing as xp_typing # pylint: disable=ungrouped-imports
- from sampletones_shared.exceptions import CuPyNotInstalledWarning # pylint: disable=ungrouped-imports
+ xp = np
- def _format_warning_no_location(
- message: Union[Warning, str],
- category: Type[Warning],
- filename: str, # pylint: disable=unused-argument
- lineno: int, # pylint: disable=unused-argument
- line: Optional[str] = None, # pylint: disable=unused-argument
- ) -> str:
- return f"{category.__name__}: {message}\n"
- warnings.formatwarning = _format_warning_no_location
- warnings.warn("CuPy is not available, falling back to NumPy.", CuPyNotInstalledWarning)
- logger.warning("CuPy is not available, falling back to NumPy.")
+def report_array_backend() -> None:
+ """States which array backend the process computes on.
- import numpy.typing as xp_typing # pylint: disable=ungrouped-imports
+ Choosing the backend is an import, and an import runs before an entry point has read the
+ verbosity it was configured with. Announcing it separately puts the line where a reader is —
+ a run of the application, or a reconstruction from the command line — and leaves a tool that
+ imports the array vocabulary for its types alone to its own output.
+ """
+ if CUPY_AVAILABLE:
+ logger.info(f"CuPy {xp.__version__} is active")
+ return
- xp = np
+ warnings.formatwarning = _format_warning_no_location
+ warnings.warn(CUPY_MISSING_MESSAGE, CuPyNotInstalledWarning)
+ logger.warning(CUPY_MISSING_MESSAGE)
def to_numpy(array: Union[np.ndarray, "xp.ndarray"]) -> np.ndarray:
@@ -75,6 +88,7 @@ def to_numpy(array: Union[np.ndarray, "xp.ndarray"]) -> np.ndarray:
__all__ = [
"CUPY_AVAILABLE",
+ "report_array_backend",
"to_numpy",
"xp",
"xp_typing",
diff --git a/src/sampletones_shared/constants/general.py b/src/sampletones_shared/constants/general.py
new file mode 100644
index 000000000..18fd8cb33
--- /dev/null
+++ b/src/sampletones_shared/constants/general.py
@@ -0,0 +1,4 @@
+from typing import Final
+
+HEXADECIMAL_BASE: Final[int] = 16
+BITS_PER_BYTE: Final[int] = 8
diff --git a/src/sampletones_shared/constants/music.py b/src/sampletones_shared/constants/music.py
index 26cb282db..13b227431 100644
--- a/src/sampletones_shared/constants/music.py
+++ b/src/sampletones_shared/constants/music.py
@@ -2,3 +2,12 @@
SEMITONE_STEP: Final[int] = 1
OCTAVE_SEMITONES: Final[int] = 12
+OCTAVE_OFFSET: Final[int] = 2
+
+LIMIT_MIN_PITCH: Final[int] = 24
+LIMIT_MAX_PITCH: Final[int] = 127
+
+A4_FREQUENCY: Final[float] = 440.0
+A4_PITCH: Final[int] = 69
+MIN_A4_FREQUENCY: Final[float] = 311.0
+MAX_A4_FREQUENCY: Final[float] = 623.0
diff --git a/src/sampletones_shared/constants/symbols.py b/src/sampletones_shared/constants/symbols.py
index 05f61f5f9..47c98799c 100644
--- a/src/sampletones_shared/constants/symbols.py
+++ b/src/sampletones_shared/constants/symbols.py
@@ -5,6 +5,7 @@
DOT: Final[str] = "."
UNDERSCORE: Final[str] = "_"
MIXED: Final[str] = "?"
+PIPE: Final[str] = "|"
MINUS: Final[str] = "-"
PLUS: Final[str] = "+"
PLUS_MINUS: Final[str] = f"{PLUS}{MINUS}"
diff --git a/src/sampletones_shared/exceptions/__init__.py b/src/sampletones_shared/exceptions/__init__.py
index 169cd46b2..05f0972fe 100644
--- a/src/sampletones_shared/exceptions/__init__.py
+++ b/src/sampletones_shared/exceptions/__init__.py
@@ -3,6 +3,15 @@
from .callback import CallbackQueueStop
from .cuda import CuPyNotInstalledWarning
from .dialog import FileDialogUnavailableError
+from .instrument import (
+ IncompatibleInstrumentVersionError,
+ InstrumentError,
+ InvalidInstrumentValuesError,
+ LoadInstrumentError,
+ MalformedInstrumentError,
+ NotAnInstrumentFileError,
+ UnsupportedInstrumentTypeError,
+)
from .language import LanguageError, MalformedTextKeyError, MissingTextError
from .library import (
IncompatibleLibraryDataVersionError,
@@ -15,6 +24,13 @@
NoLibraryDataError,
UnhandledLibraryError,
)
+from .operation import OperationCanceled
+from .player import (
+ DriverBuildError,
+ PlayerError,
+ SongTooLargeError,
+ ToolchainMissingError,
+)
from .project import (
IncompatibleProjectVersionError,
IncorrectReconstructionDataError,
@@ -38,6 +54,7 @@
DeserializationError,
InvalidMetadataError,
SerializationError,
+ TruncatedDataError,
)
from .window import WindowError, WindowNotAvailableError
@@ -46,13 +63,17 @@
"CallbackQueueStop",
"CuPyNotInstalledWarning",
"DeserializationError",
+ "DriverBuildError",
"FileDialogUnavailableError",
+ "IncompatibleInstrumentVersionError",
"IncompatibleLibraryDataVersionError",
"IncompatibleProjectVersionError",
"IncompatibleReconstructionVersionError",
"IncompleteHistogramRebinningWarning",
"IncorrectReconstructionDataError",
"InstructionTypeMismatchError",
+ "InstrumentError",
+ "InvalidInstrumentValuesError",
"InvalidLibraryDataError",
"InvalidLibraryDataValuesError",
"InvalidMetadataError",
@@ -62,23 +83,32 @@
"LanguageError",
"LibraryDisplayError",
"LibraryError",
+ "LoadInstrumentError",
"LoadLibraryError",
"LoadProjectError",
"LoadReconstructionError",
+ "MalformedInstrumentError",
"MalformedTextKeyError",
"MissingProjectDataFileError",
"MissingTextError",
"NoFilesToProcessError",
"NoLibraryDataError",
"NotAValidArchiveError",
+ "NotAnInstrumentFileError",
+ "OperationCanceled",
"PlaybackError",
+ "PlayerError",
"ReconstructionError",
"SampleToNESError",
"SerializationError",
+ "SongTooLargeError",
+ "ToolchainMissingError",
+ "TruncatedDataError",
"UnhandledLibraryError",
"UnhandledProjectError",
"UnhandledReconstructionError",
"UnsupportedAudioFormatError",
+ "UnsupportedInstrumentTypeError",
"WindowError",
"WindowNotAvailableError",
]
diff --git a/src/sampletones_shared/exceptions/instrument.py b/src/sampletones_shared/exceptions/instrument.py
new file mode 100644
index 000000000..93ef4408e
--- /dev/null
+++ b/src/sampletones_shared/exceptions/instrument.py
@@ -0,0 +1,31 @@
+from .base import SampleToNESError
+from .validation import InvalidValuesError
+from .version import IncompatibleVersionError
+
+
+class InstrumentError(SampleToNESError):
+ """Base class for instrument file errors."""
+
+
+class LoadInstrumentError(InstrumentError):
+ """Exception raised when there is an error loading an instrument file."""
+
+
+class NotAnInstrumentFileError(LoadInstrumentError):
+ """Raised when the data opens with something other than the instrument file signature."""
+
+
+class IncompatibleInstrumentVersionError(IncompatibleVersionError, LoadInstrumentError):
+ """Raised when the instrument file states a layout version other than the supported one."""
+
+
+class UnsupportedInstrumentTypeError(LoadInstrumentError):
+ """Raised when the instrument file states a chip other than the one read here."""
+
+
+class MalformedInstrumentError(LoadInstrumentError):
+ """Raised when an instrument file departs from the layout its own fields describe."""
+
+
+class InvalidInstrumentValuesError(InvalidValuesError, LoadInstrumentError):
+ """Raised when instrument data contains invalid values."""
diff --git a/src/sampletones_shared/exceptions/operation.py b/src/sampletones_shared/exceptions/operation.py
new file mode 100644
index 000000000..9abd09bcc
--- /dev/null
+++ b/src/sampletones_shared/exceptions/operation.py
@@ -0,0 +1,10 @@
+from .base import SampleToNESError
+
+
+class OperationCanceled(SampleToNESError):
+ """Raised when work in progress is withdrawn by whoever asked for it.
+
+ Long operations look up between the steps they are made of and ask the caller whether the
+ answer is still wanted. A caller that says no leaves the work unwound at that point, so the
+ boundary that started it reports a canceled run rather than a finished or failed one.
+ """
diff --git a/src/sampletones_shared/exceptions/player.py b/src/sampletones_shared/exceptions/player.py
new file mode 100644
index 000000000..419771fc1
--- /dev/null
+++ b/src/sampletones_shared/exceptions/player.py
@@ -0,0 +1,17 @@
+from .base import SampleToNESError
+
+
+class PlayerError(SampleToNESError):
+ """Base class for NES player errors."""
+
+
+class SongTooLargeError(PlayerError):
+ """Raised when a song's data outgrows the space the player has for it."""
+
+
+class DriverBuildError(PlayerError):
+ """Raised when a driver build produces something other than the image the exporter reads."""
+
+
+class ToolchainMissingError(DriverBuildError):
+ """Raised when the programs a driver build runs are absent from the system."""
diff --git a/src/sampletones_shared/exceptions/validation.py b/src/sampletones_shared/exceptions/validation.py
index 1a0cbcffc..1c9956bb9 100644
--- a/src/sampletones_shared/exceptions/validation.py
+++ b/src/sampletones_shared/exceptions/validation.py
@@ -27,3 +27,7 @@ class SerializationError(InvalidDataError):
class DeserializationError(InvalidDataError):
"""Raised when deserialization fails."""
+
+
+class TruncatedDataError(InvalidDataError):
+ """Raised when a reader is asked for more bytes than the data holds from where it stands."""
diff --git a/src/sampletones_shared/logger/main.py b/src/sampletones_shared/logger/main.py
index 9c9f256c4..da5d29401 100644
--- a/src/sampletones_shared/logger/main.py
+++ b/src/sampletones_shared/logger/main.py
@@ -16,7 +16,7 @@ class Logger(metaclass=SingletonMeta):
"""The application-wide logger, backed by a Rich console handler.
A single instance serves the whole process (through :class:`SingletonMeta`) and
- writes formatted, coloured records to the terminal. The severity methods
+ writes formatted, colored records to the terminal. The severity methods
``debug``, ``info``, ``warning``, ``error``, and ``critical`` each forward a
message to the underlying :mod:`logging` logger at that level.
"""
diff --git a/src/sampletones_shared/meta/import_boundary/__init__.py b/src/sampletones_shared/meta/import_boundary/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/src/sampletones_shared/meta/import_boundary/check.py b/src/sampletones_shared/meta/import_boundary/check.py
new file mode 100644
index 000000000..c5604faec
--- /dev/null
+++ b/src/sampletones_shared/meta/import_boundary/check.py
@@ -0,0 +1,60 @@
+from pathlib import Path
+from typing import List, Optional, Sequence, Set
+
+from sampletones_shared.meta.import_boundary.rule import BoundaryRule
+from sampletones_shared.meta.import_boundary.scope import rule_modules
+from sampletones_shared.meta.import_boundary.token import TokenRule
+from sampletones_shared.meta.import_boundary.violation import Violation
+from sampletones_shared.meta.source.modules import source_paths
+
+
+def check_boundaries(
+ source: Path,
+ rules: Sequence[BoundaryRule],
+ token_rules: Sequence[TokenRule],
+ selection: Optional[Set[Path]],
+) -> List[Violation]:
+ """Every import and token the rules forbid under a source root.
+
+ The tree is swept first, so the rules run over the modules it holds and a root reading as empty
+ stops the check where it would otherwise report a clean tree.
+
+ Args:
+ source: Source root the rule roots are named within.
+ rules: Import boundaries to hold the tree to.
+ token_rules: Spellings to keep out of the tree.
+ selection: Resolved paths to narrow the check to, or `None` to check the whole tree.
+
+ Returns:
+ List[Violation]: What the rules report, boundary rules first.
+
+ Raises:
+ NotADirectoryError: If the source root names no directory.
+ FileNotFoundError: If the source root holds no module to read.
+ """
+ swept = {path.resolve() for path in source_paths([source])}
+ violations = [
+ violation
+ for rule in rules
+ for path in rule_modules(
+ source / rule.root,
+ rule.pattern,
+ rule.excluding,
+ swept,
+ selection,
+ )
+ for violation in rule.violations(path)
+ ]
+ violations.extend(
+ violation
+ for token_rule in token_rules
+ for path in rule_modules(
+ source / token_rule.root,
+ token_rule.pattern,
+ (),
+ swept,
+ selection,
+ )
+ for violation in token_rule.violations(path)
+ )
+ return violations
diff --git a/src/sampletones_shared/meta/import_boundary/configs/__init__.py b/src/sampletones_shared/meta/import_boundary/configs/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/src/sampletones_shared/meta/import_boundary/configs/declaration.py b/src/sampletones_shared/meta/import_boundary/configs/declaration.py
new file mode 100644
index 000000000..5957647a3
--- /dev/null
+++ b/src/sampletones_shared/meta/import_boundary/configs/declaration.py
@@ -0,0 +1,60 @@
+from typing import Tuple
+
+from pydantic import BaseModel, ConfigDict
+
+from sampletones_shared.meta.import_boundary.configs.general import GeneralBoundaries
+from sampletones_shared.meta.import_boundary.rule import BoundaryRule
+
+
+class BoundaryDeclaration(BaseModel):
+ """One boundary as it is written, naming the groups it draws on.
+
+ A rule reaching for a set several others share names the group instead of repeating it, so
+ the set stays one statement. What is written here becomes the rule the check runs once the
+ groups are spelled out.
+
+ Attributes:
+ root: Directory under the source root the pattern is written against.
+ pattern: Glob naming the modules the declaration reaches.
+ forbidden_groups: Groups whose prefixes are out of reach in them.
+ forbidden: Import prefixes out of reach in them, beyond the groups'.
+ contract_groups: Groups whose prefixes are exempt from the forbidden ones.
+ contracts: Import prefixes exempt from the forbidden ones, beyond the groups'.
+ """
+
+ model_config = ConfigDict(extra="forbid", frozen=True)
+
+ root: str
+ pattern: str
+ forbidden_groups: Tuple[str, ...] = ()
+ forbidden: Tuple[str, ...] = ()
+ contract_groups: Tuple[str, ...] = ()
+ contracts: Tuple[str, ...] = ()
+
+ def rule(self, general: GeneralBoundaries) -> BoundaryRule:
+ """The rule the declaration amounts to, with every group it names spelled out.
+
+ A group's prefixes lead the ones written beside them, so the rule reads in the order it
+ was declared: the shared set first, the declaration's own prefixes after.
+
+ Args:
+ general: The names the declaration is written in.
+
+ Returns:
+ BoundaryRule: The boundary the check runs.
+
+ Raises:
+ KeyError: If the declaration names a group the vocabulary leaves out.
+ """
+ return BoundaryRule(
+ root=self.root,
+ pattern=self.pattern,
+ forbidden=(
+ *general.prefixes(self.forbidden_groups),
+ *self.forbidden,
+ ),
+ contracts=(
+ *general.prefixes(self.contract_groups),
+ *self.contracts,
+ ),
+ )
diff --git a/src/sampletones_shared/meta/import_boundary/configs/general.py b/src/sampletones_shared/meta/import_boundary/configs/general.py
new file mode 100644
index 000000000..62f6f7724
--- /dev/null
+++ b/src/sampletones_shared/meta/import_boundary/configs/general.py
@@ -0,0 +1,33 @@
+from typing import Dict, Sequence, Tuple
+
+from pydantic import BaseModel, ConfigDict
+
+
+class GeneralBoundaries(BaseModel):
+ """The names the boundary declarations are written in.
+
+ A group gathers the import prefixes several rules reach for as one thing — the interface a
+ layer stays clear of, the data contracts it may read — so the set is stated once and each
+ rule names it.
+
+ Attributes:
+ groups: Each named set of import prefixes and the prefixes it gathers.
+ """
+
+ model_config = ConfigDict(extra="forbid", frozen=True)
+
+ groups: Dict[str, Tuple[str, ...]]
+
+ def prefixes(self, names: Sequence[str]) -> Tuple[str, ...]:
+ """The import prefixes the named groups gather, in the order they are named.
+
+ Args:
+ names: Groups to spell out.
+
+ Returns:
+ Tuple[str, ...]: Every prefix those groups hold.
+
+ Raises:
+ KeyError: If a name reaches no declared group.
+ """
+ return tuple(prefix for name in names for prefix in self.groups[name])
diff --git a/src/sampletones_shared/meta/import_boundary/configs/paths.py b/src/sampletones_shared/meta/import_boundary/configs/paths.py
new file mode 100644
index 000000000..92ac72066
--- /dev/null
+++ b/src/sampletones_shared/meta/import_boundary/configs/paths.py
@@ -0,0 +1,6 @@
+from pathlib import Path
+from typing import Final
+
+from sampletones_shared.paths.resources import CONFIG_DIRECTORY
+
+BOUNDARIES_DIRECTORY: Final[Path] = CONFIG_DIRECTORY / "boundaries"
diff --git a/src/sampletones_shared/meta/import_boundary/configs/rules.py b/src/sampletones_shared/meta/import_boundary/configs/rules.py
new file mode 100644
index 000000000..b4fc43a46
--- /dev/null
+++ b/src/sampletones_shared/meta/import_boundary/configs/rules.py
@@ -0,0 +1,74 @@
+from typing import Dict, Self, Tuple
+
+from pydantic import BaseModel, ConfigDict, model_validator
+
+from sampletones_shared.meta.import_boundary.configs.declaration import BoundaryDeclaration
+from sampletones_shared.meta.import_boundary.configs.general import GeneralBoundaries
+from sampletones_shared.meta.import_boundary.configs.paths import BOUNDARIES_DIRECTORY
+from sampletones_shared.meta.import_boundary.graph import LayerGraph
+from sampletones_shared.meta.import_boundary.rule import BoundaryRule
+from sampletones_shared.meta.import_boundary.token import TokenRule
+from sampletones_shared.utils.serialization import load_yaml_model_dir
+
+
+class ImportBoundaryRules(BaseModel):
+ """Every boundary the source tree is held to, as the shipped configuration states it.
+
+ The declaration comes in three forms, each a fragment of its own. A layer graph names a tree
+ of modules and what each unit may import, and amounts to one rule per unit. A boundary
+ declaration names one directory and the imports it stays clear of. A token rule names a
+ spelling a tree keeps out. The general vocabulary holds the prefix sets the declarations draw
+ on, so a set several of them reach for is written once.
+
+ Attributes:
+ general: The names the declarations are written in.
+ graphs: Each layer graph the source tree divides into, under the name the documents give it.
+ rules: The boundaries written directly.
+ tokens: The spellings kept out of the trees they name.
+ """
+
+ model_config = ConfigDict(extra="forbid", frozen=True)
+
+ general: GeneralBoundaries
+ graphs: Dict[str, LayerGraph]
+ rules: Tuple[BoundaryDeclaration, ...]
+ tokens: Tuple[TokenRule, ...]
+
+ @model_validator(mode="after")
+ def _validate_every_named_group_is_declared(self) -> Self:
+ """Holds each declaration to the vocabulary, so a name reaching no group is refused as it is read.
+
+ Raises:
+ ValueError: If a declaration names a group the general configuration leaves out.
+ """
+ named = {
+ name for declaration in self.rules for name in (*declaration.forbidden_groups, *declaration.contract_groups)
+ }
+ unknown = sorted(named - set(self.general.groups))
+ if unknown:
+ raise ValueError(f"the declarations name groups the vocabulary leaves out: {', '.join(unknown)}")
+
+ return self
+
+ @classmethod
+ def load(cls) -> Self:
+ """The boundaries the build ships.
+
+ Returns:
+ Self: The declaration validated from `sampletones_config/boundaries/`.
+
+ Raises:
+ TypeError: If a fragment holds anything other than what its field states.
+ """
+ return load_yaml_model_dir(BOUNDARIES_DIRECTORY, cls)
+
+ def boundary_rules(self) -> Tuple[BoundaryRule, ...]:
+ """Every import boundary the check runs, the graphs' rules first and the declared ones after.
+
+ Returns:
+ Tuple[BoundaryRule, ...]: The rules the declaration amounts to, in declaration order.
+ """
+ return (
+ *(rule for graph in self.graphs.values() for rule in graph.rules()),
+ *(declaration.rule(self.general) for declaration in self.rules),
+ )
diff --git a/src/sampletones_shared/meta/import_boundary/graph.py b/src/sampletones_shared/meta/import_boundary/graph.py
new file mode 100644
index 000000000..dede55c51
--- /dev/null
+++ b/src/sampletones_shared/meta/import_boundary/graph.py
@@ -0,0 +1,109 @@
+from typing import Dict, Final, List, Mapping, Self, Set, Tuple
+
+from pydantic import BaseModel, ConfigDict, model_validator
+
+from sampletones_shared.meta.import_boundary.rule import BoundaryRule
+from sampletones_shared.meta.import_boundary.units import (
+ nested_globs,
+ unit_glob,
+ unit_prefix,
+)
+
+SOURCE_TREE: Final[str] = ""
+
+
+def reached_units(layers: Mapping[str, Tuple[str, ...]], unit: str) -> Set[str]:
+ """Every unit one unit imports, directly or through the units it imports.
+
+ Args:
+ layers: Each unit and the units it may import.
+ unit: Unit to walk out from.
+
+ Returns:
+ Set[str]: The units it reaches, itself among them where the graph closes a cycle.
+
+ Raises:
+ KeyError: If a unit is reached that the layers leave undeclared.
+ """
+ reached: Set[str] = set()
+ pending = [unit]
+ while pending:
+ for allowed in layers[pending.pop()]:
+ if allowed not in reached:
+ reached.add(allowed)
+ pending.append(allowed)
+
+ return reached
+
+
+class LayerGraph(BaseModel):
+ """A tree of modules, the units it divides into, and what each unit may import.
+
+ Attributes:
+ root: Directory under the source root the units are named within, empty where the units
+ sit at the source root itself.
+ package: Import prefix the units sit under, empty where the units are packages themselves.
+ layers: Each unit and the units it may import.
+ """
+
+ model_config = ConfigDict(extra="forbid", frozen=True)
+
+ root: str = SOURCE_TREE
+ package: str = SOURCE_TREE
+ layers: Dict[str, Tuple[str, ...]]
+
+ @model_validator(mode="after")
+ def _validate_every_layer_a_unit_may_import_is_declared(self) -> Self:
+ """Holds each unit's layers to the units the graph divides into.
+
+ Raises:
+ ValueError: If a unit may import something the graph leaves undeclared.
+ """
+ undeclared = sorted(
+ {allowed for layers in self.layers.values() for allowed in layers} - set(self.layers),
+ )
+ if undeclared:
+ raise ValueError(f"the graph leaves the units it reaches undeclared: {', '.join(undeclared)}")
+
+ return self
+
+ @model_validator(mode="after")
+ def _validate_the_graph_is_acyclic(self) -> Self:
+ """Holds the units to an order, which is what makes a unit's layers state a level.
+
+ Raises:
+ ValueError: If a unit reaches itself through the units it may import.
+ """
+ looping = sorted(unit for unit in self.layers if unit in reached_units(self.layers, unit))
+ if looping:
+ raise ValueError(f"the units reach themselves through the graph: {', '.join(looping)}")
+
+ return self
+
+ def rules(self) -> List[BoundaryRule]:
+ """One rule per unit, forbidding every unit its layers leave out.
+
+ Declaring what a unit may import states the graph once, and the rule the check runs is what
+ remains — so an edge the graph leaves out is reported wherever it is taken. A unit declared
+ inside another owns its own modules, which is how a subpackage states a boundary of its own
+ inside the one around it.
+
+ Returns:
+ List[BoundaryRule]: The rules the graph amounts to, in declaration order.
+ """
+ return [
+ BoundaryRule(
+ root=self.root,
+ pattern=unit_glob(unit),
+ forbidden=tuple(
+ unit_prefix(
+ self.package,
+ other,
+ )
+ for other in self.layers
+ if other != unit and other not in allowed
+ ),
+ excluding=nested_globs(unit, self.layers),
+ )
+ for unit, allowed in self.layers.items()
+ ]
diff --git a/src/sampletones_shared/meta/import_boundary/imports.py b/src/sampletones_shared/meta/import_boundary/imports.py
new file mode 100644
index 000000000..5a4f50508
--- /dev/null
+++ b/src/sampletones_shared/meta/import_boundary/imports.py
@@ -0,0 +1,24 @@
+import re
+from typing import Final, Optional
+
+from sampletones_shared.meta.source.modules import MODULE_SEPARATOR
+
+IMPORT_PATTERN: Final[re.Pattern[str]] = re.compile(r"^\s*(import|from)\s+([\w.]+)")
+
+
+def imported_module(line: str) -> Optional[str]:
+ """The dotted module one line of source imports.
+
+ Args:
+ line: Line to read.
+
+ Returns:
+ Optional[str]: The module the line imports, or `None` where the line imports nothing.
+ """
+ match = IMPORT_PATTERN.match(line)
+ return match.group(2) if match is not None else None
+
+
+def matches_prefix(module: str, prefix: str) -> bool:
+ """Whether a dotted module is the prefix itself or a module underneath it."""
+ return module == prefix or module.startswith(prefix + MODULE_SEPARATOR)
diff --git a/src/sampletones_shared/meta/import_boundary/lines.py b/src/sampletones_shared/meta/import_boundary/lines.py
new file mode 100644
index 000000000..3b6f2d28a
--- /dev/null
+++ b/src/sampletones_shared/meta/import_boundary/lines.py
@@ -0,0 +1,25 @@
+from pathlib import Path
+from typing import Iterator, Tuple
+
+from sampletones_shared.meta.source.modules import SOURCE_ENCODING
+
+
+def numbered_lines(path: Path) -> Iterator[Tuple[int, str]]:
+ """Each line of a module paired with the number a report points a reader at.
+
+ A boundary is stated over the source as it is written rather than over the tree it parses to,
+ so a report quotes the line a reader opens and an unparseable module is still checked.
+
+ Args:
+ path: Module to read.
+
+ Yields:
+ Tuple[int, str]: The line number, counting from one, and the line.
+
+ Raises:
+ OSError: If the module cannot be read.
+ """
+ yield from enumerate(
+ path.read_text(encoding=SOURCE_ENCODING).splitlines(),
+ start=1,
+ )
diff --git a/src/sampletones_shared/meta/import_boundary/rule.py b/src/sampletones_shared/meta/import_boundary/rule.py
new file mode 100644
index 000000000..8ab77c3d5
--- /dev/null
+++ b/src/sampletones_shared/meta/import_boundary/rule.py
@@ -0,0 +1,82 @@
+from pathlib import Path
+from typing import List, Tuple
+
+from pydantic import BaseModel, ConfigDict
+
+from sampletones_shared.meta.import_boundary.imports import (
+ imported_module,
+ matches_prefix,
+)
+from sampletones_shared.meta.import_boundary.lines import numbered_lines
+from sampletones_shared.meta.import_boundary.violation import Violation
+
+
+class BoundaryRule(BaseModel):
+ """One tree of modules and the imports it stays clear of.
+
+ Attributes:
+ root: Directory under the source root the pattern is written against.
+ pattern: Glob naming the modules the rule reaches.
+ forbidden: Import prefixes out of reach in them.
+ contracts: Import prefixes exempt from the forbidden ones.
+ excluding: Globs naming the modules a rule of their own owns instead.
+ """
+
+ model_config = ConfigDict(extra="forbid", frozen=True)
+
+ root: str
+ pattern: str
+ forbidden: Tuple[str, ...]
+ contracts: Tuple[str, ...] = ()
+ excluding: Tuple[str, ...] = ()
+
+ def violations(self, path: Path) -> List[Violation]:
+ """Every import one module takes that the rule forbids.
+
+ A contract is read first, so a module named by one is reached even where the prefix around
+ it is out of bounds. The first forbidden prefix an import matches names the violation, since
+ one import crosses one boundary.
+
+ Args:
+ path: Module to read.
+
+ Returns:
+ List[Violation]: What the module imports past the boundary, in line order.
+
+ Raises:
+ OSError: If the module cannot be read.
+ """
+ violations: List[Violation] = []
+ for line_number, line in numbered_lines(path):
+ module = imported_module(line)
+ if module is None or any(
+ matches_prefix(
+ module,
+ contract,
+ )
+ for contract in self.contracts
+ ):
+ continue
+
+ crossed = next(
+ (
+ prefix
+ for prefix in self.forbidden
+ if matches_prefix(
+ module,
+ prefix,
+ )
+ ),
+ None,
+ )
+ if crossed is not None:
+ violations.append(
+ Violation.at(
+ crossed,
+ path,
+ line_number,
+ line,
+ )
+ )
+
+ return violations
diff --git a/src/sampletones_shared/meta/import_boundary/scope.py b/src/sampletones_shared/meta/import_boundary/scope.py
new file mode 100644
index 000000000..86aa45c28
--- /dev/null
+++ b/src/sampletones_shared/meta/import_boundary/scope.py
@@ -0,0 +1,34 @@
+from pathlib import Path
+from typing import List, Optional, Set, Tuple
+
+
+def rule_modules(
+ root: Path,
+ pattern: str,
+ excluding: Tuple[str, ...],
+ swept: Set[Path],
+ selection: Optional[Set[Path]],
+) -> List[Path]:
+ """The modules a rule reaches, in path order.
+
+ A rule names its files by one glob whether the check runs over the whole tree or over the files
+ a hook lists, so the two entry points read the same rule the same way. A module a nested rule
+ owns belongs to that rule alone, which is how a subpackage states a boundary of its own inside
+ the one around it.
+
+ Args:
+ root: Directory the rule globs are written against.
+ pattern: Glob the rule names its files by.
+ excluding: Globs naming the modules a rule of their own owns instead.
+ swept: Visible modules the tree holds, which the glob is held to.
+ selection: Resolved paths to narrow the rule to, or `None` to reach every module it names.
+
+ Returns:
+ List[Path]: The modules the rule applies to.
+ """
+ owned = {path.resolve() for nested in excluding for path in root.glob(nested)}
+ matched = ({path.resolve() for path in root.glob(pattern)} & swept) - owned
+ if selection is not None:
+ matched &= selection
+
+ return sorted(matched)
diff --git a/src/sampletones_shared/meta/import_boundary/token.py b/src/sampletones_shared/meta/import_boundary/token.py
new file mode 100644
index 000000000..51891e26d
--- /dev/null
+++ b/src/sampletones_shared/meta/import_boundary/token.py
@@ -0,0 +1,63 @@
+import re
+from pathlib import Path
+from typing import List
+
+from pydantic import BaseModel, ConfigDict, field_validator
+
+from sampletones_shared.meta.import_boundary.lines import numbered_lines
+from sampletones_shared.meta.import_boundary.violation import Violation
+
+
+class TokenRule(BaseModel):
+ """One tree of modules and a spelling that stays out of them.
+
+ Attributes:
+ root: Directory under the source root the pattern is written against.
+ pattern: Glob naming the modules the rule reaches.
+ forbidden: Regular expression the modules stay clear of.
+ message: What the rule holds, printed where a module writes the spelling.
+ """
+
+ model_config = ConfigDict(extra="forbid", frozen=True)
+
+ root: str
+ pattern: str
+ forbidden: str
+ message: str
+
+ @field_validator("forbidden")
+ @classmethod
+ def _validate_the_spelling_is_a_usable_expression(
+ cls,
+ forbidden: str,
+ ) -> str:
+ """Holds the spelling to what `re` accepts, so a rule reports its own defect as it is read.
+
+ Raises:
+ ValueError: If the spelling is no valid regular expression.
+ """
+ try:
+ re.compile(forbidden)
+ except re.error as error:
+ raise ValueError(f"the spelling {forbidden!r} is no valid regular expression: {error}") from error
+
+ return forbidden
+
+ def violations(self, path: Path) -> List[Violation]:
+ """Every line of one module that writes the forbidden spelling.
+
+ Args:
+ path: Module to read.
+
+ Returns:
+ List[Violation]: The lines the rule reports, in line order.
+
+ Raises:
+ OSError: If the module cannot be read.
+ """
+ spelling = re.compile(self.forbidden)
+ return [
+ Violation.at(self.message, path, line_number, line)
+ for line_number, line in numbered_lines(path)
+ if spelling.search(line)
+ ]
diff --git a/src/sampletones_shared/meta/import_boundary/units.py b/src/sampletones_shared/meta/import_boundary/units.py
new file mode 100644
index 000000000..c87b06d0a
--- /dev/null
+++ b/src/sampletones_shared/meta/import_boundary/units.py
@@ -0,0 +1,34 @@
+from typing import Final, Iterable, Tuple
+
+from sampletones_shared.meta.source.modules import MODULE_SEPARATOR
+
+MODULE_SUFFIX: Final[str] = ".py"
+PATH_SEPARATOR: Final[str] = "/"
+UNIT_PATTERN: Final[str] = "**/*.py"
+
+
+def unit_prefix(package: str, unit: str) -> str:
+ """The import prefix a unit is reached by.
+
+ Args:
+ package: Import prefix the unit sits under, empty where the unit is a package itself.
+ unit: Unit named as a path under the graph's root.
+
+ Returns:
+ str: The dotted prefix an import of that unit begins with.
+ """
+ name = unit.removesuffix(MODULE_SUFFIX).replace(
+ PATH_SEPARATOR,
+ MODULE_SEPARATOR,
+ )
+ return f"{package}{MODULE_SEPARATOR}{name}" if package else name
+
+
+def unit_glob(unit: str) -> str:
+ """The glob naming the modules a unit holds, whether the unit is a module or a directory."""
+ return unit if unit.endswith(MODULE_SUFFIX) else f"{unit}{PATH_SEPARATOR}{UNIT_PATTERN}"
+
+
+def nested_globs(unit: str, units: Iterable[str]) -> Tuple[str, ...]:
+ """The globs of the units declared inside another one, which own their modules instead."""
+ return tuple(unit_glob(other) for other in units if other.startswith(f"{unit}{PATH_SEPARATOR}"))
diff --git a/src/sampletones_shared/meta/import_boundary/violation.py b/src/sampletones_shared/meta/import_boundary/violation.py
new file mode 100644
index 000000000..42eb61260
--- /dev/null
+++ b/src/sampletones_shared/meta/import_boundary/violation.py
@@ -0,0 +1,32 @@
+from pathlib import Path
+from typing import NamedTuple, Self
+
+
+class Violation(NamedTuple):
+ """One import or token a rule forbids, and where a reader opens it.
+
+ Attributes:
+ kind: What the rule forbids, spelled the way the report names it.
+ location: The module, the line number and the line itself.
+ """
+
+ kind: str
+ location: str
+
+ @classmethod
+ def at(cls, kind: str, path: Path, line_number: int, line: str) -> Self:
+ """One violation located as `path:line`, the form an editor jumps to.
+
+ Args:
+ kind: What the rule forbids.
+ path: Module the line sits in.
+ line_number: Line the violation sits on, counting from one.
+ line: The line itself, quoted stripped of its indentation.
+
+ Returns:
+ Self: The violation a report prints.
+ """
+ return cls(
+ kind=kind,
+ location=f"{path}:{line_number}: {line.strip()}",
+ )
diff --git a/src/sampletones_shared/meta/source/annotations.py b/src/sampletones_shared/meta/source/annotations.py
index 864655523..5994feb7e 100644
--- a/src/sampletones_shared/meta/source/annotations.py
+++ b/src/sampletones_shared/meta/source/annotations.py
@@ -58,7 +58,7 @@ def annotation_type_name(annotation: Optional[ast.expr]) -> Optional[str]:
def annotation_item_types(annotation: Optional[ast.expr]) -> Tuple[str, ...]:
"""The type names a container annotation states for what it holds.
- `Dict[TrackerFormat, FileFilterElements]` states its key type and then its value type, and
+ `Dict[ExportFormat, FileFilterElements]` states its key type and then its value type, and
`Tuple[MenuElements, ...]` states its item type.
Args:
diff --git a/src/sampletones_shared/music.py b/src/sampletones_shared/music.py
new file mode 100644
index 000000000..e94165387
--- /dev/null
+++ b/src/sampletones_shared/music.py
@@ -0,0 +1,51 @@
+from typing import Annotated
+
+from pydantic import BaseModel, Field
+
+from sampletones_shared.constants.music import (
+ A4_FREQUENCY,
+ A4_PITCH,
+ LIMIT_MAX_PITCH,
+ LIMIT_MIN_PITCH,
+ MAX_A4_FREQUENCY,
+ MIN_A4_FREQUENCY,
+)
+from sampletones_shared.utils.frequencies import pitch_to_frequency
+
+ReferenceFrequency = Annotated[float, Field(gt=MIN_A4_FREQUENCY, lt=MAX_A4_FREQUENCY)]
+ReferencePitch = Annotated[int, Field(ge=LIMIT_MIN_PITCH, le=LIMIT_MAX_PITCH)]
+
+
+class Tuning(BaseModel, frozen=True, extra="forbid"):
+ """Where concert pitch sits, which is what fixes the frequency every pitch sounds at.
+
+ Equal temperament measures the whole scale from one reference, so stating that reference
+ states every pitch. A reconstruction is built against a tuning of its own, and anything
+ sounding or exporting its pitches reads that tuning to stay in tune with it.
+
+ Attributes:
+ a4_frequency: Frequency in Hz the reference pitch sounds at.
+ a4_pitch: The pitch the reference frequency names.
+ """
+
+ a4_frequency: ReferenceFrequency = Field(
+ default=A4_FREQUENCY,
+ )
+ a4_pitch: ReferencePitch = Field(
+ default=A4_PITCH,
+ )
+
+ def frequency(self, pitch: int) -> float:
+ """The frequency a pitch sounds at under this tuning.
+
+ Args:
+ pitch: The pitch to sound.
+
+ Returns:
+ float: The frequency in Hz.
+
+ Raises:
+ TypeError: If the pitch is not an integer.
+ ValueError: If the pitch lies outside the range the project covers.
+ """
+ return pitch_to_frequency(pitch, self.a4_frequency, self.a4_pitch)
diff --git a/src/sampletones_shared/paths/extensions.py b/src/sampletones_shared/paths/extensions.py
index 857c1c911..783e3e5d2 100644
--- a/src/sampletones_shared/paths/extensions.py
+++ b/src/sampletones_shared/paths/extensions.py
@@ -8,6 +8,7 @@
EXT_FILE_PROJECT: Final[str] = ".stp"
EXT_FILE_MODULE: Final[str] = ".ftm"
EXT_FILE_BITPHASE: Final[str] = ".btp"
+EXT_FILE_NSF: Final[str] = ".nsf"
EXT_FILE_WAVE: Final[str] = ".wav"
EXT_FILE_MP3: Final[str] = ".mp3"
EXT_FILE_FLAC: Final[str] = ".flac"
diff --git a/src/sampletones_shared/utils/color.py b/src/sampletones_shared/utils/color.py
index 63f2a2507..69e9ba481 100644
--- a/src/sampletones_shared/utils/color.py
+++ b/src/sampletones_shared/utils/color.py
@@ -3,6 +3,7 @@
import numpy as np
from pydantic import BeforeValidator
+from sampletones_shared.constants.general import HEXADECIMAL_BASE
from sampletones_shared.types.application import ColorRGBA
from sampletones_shared.utils.arrays import clamp
@@ -12,19 +13,19 @@
def with_alpha_fraction(color: ColorRGBA, fraction: float) -> ColorRGBA:
"""Return ``color`` with its alpha set to ``fraction`` of full opacity.
- ``fraction`` is a value in ``[0, 1]``; ``1`` keeps the colour fully opaque and
+ ``fraction`` is a value in ``[0, 1]``; ``1`` keeps the color fully opaque and
``0`` makes it fully transparent, letting callers express a tint strength as a
- fraction while colours stay 8-bit RGBA tuples.
+ fraction while colors stay 8-bit RGBA tuples.
"""
red, green, blue, _ = color
return (red, green, blue, round(fraction * MAX_CHANNEL_VALUE))
def blend(start: ColorRGBA, end: ColorRGBA, fraction: float) -> ColorRGBA:
- """Linearly interpolate between two colours, channel by channel.
+ """Linearly interpolate between two colors, channel by channel.
``fraction`` is clamped to ``[0, 1]``: ``0`` returns ``start`` and ``1`` returns ``end``, with
- every RGBA channel mixed in proportion so a scalar can drive a colour along a gradient.
+ every RGBA channel mixed in proportion so a scalar can drive a color along a gradient.
"""
ratio = clamp(fraction, 0.0, 1.0)
start_channels = np.array(start, dtype=np.float64)
@@ -34,9 +35,9 @@ def blend(start: ColorRGBA, end: ColorRGBA, fraction: float) -> ColorRGBA:
def composite(base: ColorRGBA, overlay: ColorRGBA) -> ColorRGBA:
- """Return the colour ``overlay`` makes when it is drawn over ``base``.
+ """Return the color ``overlay`` makes when it is drawn over ``base``.
- Each colour carries its own alpha, and the result carries the coverage the two reach
+ Each color carries its own alpha, and the result carries the coverage the two reach
together, so a pair of translucent washes bound for a single layer reads as it would if
the layer held both. A fully transparent pair returns ``base``.
"""
@@ -55,7 +56,7 @@ def composite(base: ColorRGBA, overlay: ColorRGBA) -> ColorRGBA:
def to_grayscale(color: ColorRGBA) -> ColorRGBA:
"""Return ``color`` desaturated to its luminance-preserving gray, keeping its alpha.
- The RGB channels collapse to one perceptual-luminance value, so a coloured line reads
+ The RGB channels collapse to one perceptual-luminance value, so a colored line reads
as an inactive gray while its alpha stays under the caller's separate control.
"""
red, green, blue, alpha = color
@@ -87,14 +88,14 @@ def parse_hex_color(value: str) -> ColorRGBA:
raise ValueError(f"Color must have 6 or 8 hex digits after '#', got {len(hex_part)}: {value!r}")
try:
- int(hex_part, 16)
+ int(hex_part, HEXADECIMAL_BASE)
except ValueError as exception:
raise ValueError(f"Color contains non-hex characters: {value!r}") from exception
- r = int(hex_part[0:2], 16)
- g = int(hex_part[2:4], 16)
- b = int(hex_part[4:6], 16)
- a = int(hex_part[6:8], 16) if len(hex_part) == 8 else 255
+ r = int(hex_part[0:2], HEXADECIMAL_BASE)
+ g = int(hex_part[2:4], HEXADECIMAL_BASE)
+ b = int(hex_part[4:6], HEXADECIMAL_BASE)
+ a = int(hex_part[6:8], HEXADECIMAL_BASE) if len(hex_part) == 8 else 255
return (r, g, b, a)
diff --git a/src/sampletones_shared/utils/frequencies.py b/src/sampletones_shared/utils/frequencies.py
new file mode 100644
index 000000000..b0ba02de2
--- /dev/null
+++ b/src/sampletones_shared/utils/frequencies.py
@@ -0,0 +1,143 @@
+import numpy as np
+
+from sampletones_shared.constants.music import (
+ A4_FREQUENCY,
+ A4_PITCH,
+ LIMIT_MAX_PITCH,
+ LIMIT_MIN_PITCH,
+ OCTAVE_SEMITONES,
+)
+
+
+def validate_pitch(pitch: int) -> None:
+ """
+ Validates that a pitch value is an integer within the range 24-127.
+
+ Args:
+ pitch: The pitch value to validate.
+
+ Raises:
+ TypeError: If pitch is not an integer.
+ ValueError: If pitch is outside the range 24-127.
+ """
+ if not isinstance(pitch, int):
+ raise TypeError("Pitch must be an integer value")
+
+ if not LIMIT_MIN_PITCH <= pitch <= LIMIT_MAX_PITCH:
+ raise ValueError(f"Pitch must be in the range {LIMIT_MIN_PITCH}-{LIMIT_MAX_PITCH}")
+
+
+def validate_frequency(frequency: float) -> None:
+ """
+ Validates that a frequency value is a positive finite number.
+
+ Args:
+ frequency: The frequency value to validate.
+
+ Raises:
+ TypeError: If frequency is not a numeric type.
+ ValueError: If frequency is not a positive finite number.
+ """
+ if not isinstance(frequency, (int, float)):
+ raise TypeError("Frequency must be a numeric value")
+
+ if np.isinf(frequency) or np.isnan(frequency):
+ raise ValueError("Frequency must be a positive finite number")
+
+ if frequency <= 0:
+ raise ValueError("Frequency must be a positive value")
+
+
+def pitch_to_frequency(
+ pitch: int,
+ a4_frequency: float = A4_FREQUENCY,
+ a4_pitch: int = A4_PITCH,
+) -> float:
+ """
+ Converts a MIDI-style pitch value to its corresponding frequency in Hz.
+
+ Uses the equal temperament tuning system where each semitone is separated
+ by a factor of 2^(1/12).
+
+ Args:
+ pitch: The MIDI pitch number (24-127, where 69 is typically A4).
+ a4_frequency: The reference frequency for A4 in Hz. Defaults to 440.0 Hz.
+ a4_pitch: The MIDI pitch number for A4. Defaults to 69.
+
+ Returns:
+ The frequency in Hz corresponding to the given pitch.
+
+ Raises:
+ TypeError: If pitch is not an integer.
+ TypeError: If a4_frequency is not a numeric type.
+ TypeError: If a4_pitch is not an integer.
+ ValueError: If pitch is outside the range 24-127.
+ ValueError: If a4_pitch is outside the range 24-127.
+ ValueError: If a4_frequency is not a positive finite number.
+ ValueError: If calculated frequency is not a positive finite number.
+
+ Examples:
+ >>> pitch_to_frequency(69) # A4
+ 440.0
+ >>> pitch_to_frequency(57) # A3 (one octave below A4)
+ 220.0
+ >>> pitch_to_frequency(60) # Middle C (C4)
+ 261.6255653005986
+ >>> pitch_to_frequency(69, a4_frequency=432.0) # A4 with different tuning
+ 432.0
+ """
+ validate_pitch(pitch)
+ validate_pitch(a4_pitch)
+ validate_frequency(a4_frequency)
+
+ frequency: float = a4_frequency * (2 ** ((pitch - a4_pitch) / OCTAVE_SEMITONES))
+ validate_frequency(frequency)
+ return frequency
+
+
+def frequency_to_pitch(
+ frequency: float,
+ a4_frequency: float = A4_FREQUENCY,
+ a4_pitch: int = A4_PITCH,
+) -> int:
+ """
+ Converts a frequency in Hz to the nearest MIDI-style pitch value.
+
+ Uses logarithmic conversion based on the equal temperament tuning system.
+ Returns 0 for frequencies at or below 0 Hz.
+
+ Args:
+ frequency: The frequency in Hz to convert.
+ a4_frequency: The reference frequency for A4 in Hz. Defaults to 440.0 Hz.
+ a4_pitch: The MIDI pitch number for A4. Defaults to 69.
+
+ Returns:
+ The nearest integer MIDI pitch number.
+
+ Raises:
+ TypeError: If frequency is not a numeric type.
+ TypeError: If a4_frequency is not a numeric type.
+ TypeError: If a4_pitch is not an integer.
+ ValueError: If frequency is not a positive finite number.
+ ValueError: If a4_frequency is not a positive finite number.
+ ValueError: If calculated pitch is outside the range 24-127.
+
+ Examples:
+ >>> frequency_to_pitch(440.0) # A4
+ 69
+ >>> frequency_to_pitch(880.0) # A5
+ 81
+ >>> frequency_to_pitch(261.63) # ~middle C
+ 60
+ >>> frequency_to_pitch(0.0) # invalid frequency
+ Traceback (most recent call last):
+ ...
+ ValueError: Frequency must be a positive value
+ """
+ validate_frequency(frequency)
+ validate_frequency(a4_frequency)
+ validate_pitch(a4_pitch)
+
+ pitch: int = round(a4_pitch + OCTAVE_SEMITONES * (np.log2(frequency / a4_frequency)))
+ validate_pitch(pitch)
+ return pitch
diff --git a/src/sampletones_shared/utils/progress.py b/src/sampletones_shared/utils/progress.py
new file mode 100644
index 000000000..bdb0e96eb
--- /dev/null
+++ b/src/sampletones_shared/utils/progress.py
@@ -0,0 +1,66 @@
+from typing import Final, Optional
+
+PROGRESS_STEPS: Final[int] = 200
+
+
+def silent_reporter(progress: object) -> bool: # pylint: disable=unused-argument
+ """Answers that the run goes on, which is what a caller watching nothing asks of it.
+
+ A run reports through a reporter that answers whether its answer is still wanted, so a caller
+ with nothing to watch supplies this one and hears the run through to its end. The report is
+ taken as it comes, since a caller that reads none of it holds no opinion on its shape.
+ """
+ return True
+
+
+def report_interval(total: int) -> int:
+ """How far a count travels between two reports of a stage measured against ``total``.
+
+ A stage may step through millions of samples or a handful of files, so reporting every step
+ fills a queue with updates no eye resolves and no bar redraws. Spacing the reports over a
+ fixed number of steps holds the rate steady whatever the stage counts in.
+
+ Args:
+ total: What the stage's count is measured against.
+
+ Returns:
+ int: The count a stage covers between reports, which is at least one.
+ """
+ return max(1, total // PROGRESS_STEPS)
+
+
+class ReportRate:
+ """How often a stage measured against a total is worth saying something about.
+
+ What a stage counts moves by its own rules: a render's samples rise toward the song's length,
+ while a compression's bytes fall as the dictionary earns its keep. A step is therefore a change
+ of either sign, and a stage landing exactly on its total is always due, so a reading arrives at
+ the end of every stage however it traveled there.
+
+ The first reading a stage offers is due whatever it says, since a stage announcing where it
+ begins is news to whoever is watching for it.
+ """
+
+ def __init__(self, total: int) -> None:
+ """Holds the spacing a stage measured against ``total`` is reported at."""
+ self._total = total
+ self._interval = report_interval(total)
+ self._reported: Optional[int] = None
+
+ def take(self, completed: int) -> bool:
+ """Takes ``completed`` as the reading to report, and answers whether it is due.
+
+ A reading it takes is what the next one is measured from, so a stage moving in small steps
+ is reported at the spacing this holds rather than at every step it makes.
+
+ Args:
+ completed: What the stage has covered so far, in the unit the stage counts in.
+
+ Returns:
+ bool: Whether the stage has moved far enough to be worth reporting.
+ """
+ if self._reported is not None and completed != self._total and abs(completed - self._reported) < self._interval:
+ return False
+
+ self._reported = completed
+ return True
diff --git a/src/sampletones_shared/utils/serialization.py b/src/sampletones_shared/utils/serialization.py
index 0cfca3e5b..e127ebc0f 100644
--- a/src/sampletones_shared/utils/serialization.py
+++ b/src/sampletones_shared/utils/serialization.py
@@ -132,7 +132,7 @@ def load_yaml_model(
model_type (Type[ModelTypeT]): Model class validating the mapping.
context (Optional[Mapping[str, Any]]): Validation context forwarded to
``model_validate``, letting field validators resolve against shared state
- (e.g. a palette for colour references).
+ (e.g. a palette for color references).
Returns:
ModelTypeT: The validated model instance.
@@ -166,7 +166,7 @@ def load_yaml_model_dir(
model_type (Type[ModelTypeT]): Model class validating the merged mapping.
context (Optional[Mapping[str, Any]]): Validation context forwarded to
``model_validate``, letting field validators resolve against shared state
- (e.g. a palette for colour references).
+ (e.g. a palette for color references).
Returns:
ModelTypeT: The validated model instance.
diff --git a/src/sampletones_shared/utils/system/modules.py b/src/sampletones_shared/utils/system/modules.py
new file mode 100644
index 000000000..38ae8771c
--- /dev/null
+++ b/src/sampletones_shared/utils/system/modules.py
@@ -0,0 +1,21 @@
+import importlib.util
+from typing import Final
+
+JEEPNEY_MODULE: Final[str] = "jeepney"
+TKINTER_MODULE: Final[str] = "tkinter"
+
+
+def module_available(module: str) -> bool:
+ """Whether this interpreter imports a module, which is how an optional dependency is reached.
+
+ A dependency declared for one platform, or shipped as a separate system package, is present on
+ some machines and absent on others. Probing the name keeps the import inside the branch that
+ needs it, so startup stands on what the machine actually carries.
+
+ Args:
+ module: The module's importable name.
+
+ Returns:
+ bool: ``True`` where this interpreter finds the module installed.
+ """
+ return importlib.util.find_spec(module) is not None
diff --git a/src/sampletones_shared/utils/system/paths.py b/src/sampletones_shared/utils/system/paths.py
index 3b3ce37fa..fe2176d63 100644
--- a/src/sampletones_shared/utils/system/paths.py
+++ b/src/sampletones_shared/utils/system/paths.py
@@ -1,7 +1,7 @@
import os
import subprocess
from pathlib import Path
-from typing import Final, Optional
+from typing import Final, Optional, Sequence, Tuple, Union
from sampletones_shared.types.path import GeneralPathlike, Pathlike
@@ -198,6 +198,52 @@ def get_directory(path: Pathlike) -> Path:
return path if path.is_dir() else path.parent
+def to_paths(
+ location: Optional[Union[Pathlike, Tuple[Pathlike, ...]]],
+) -> Tuple[Path, ...]:
+ """
+ Returns the recorded location as a tuple of paths.
+
+ One path becomes a one-tuple, several paths stay as they are, and an absent
+ location becomes an empty tuple — one shape for every form a recorded source
+ takes.
+
+ Args:
+ location: The recorded location, or ``None``.
+
+ Returns:
+ Tuple[Path, ...]: The paths in order, empty for an absent location.
+ """
+ if location is None:
+ return ()
+
+ if isinstance(location, (str, Path, os.PathLike)):
+ return (to_path(location),)
+
+ return tuple(to_path(path) for path in location)
+
+
+def first_missing(paths: Sequence[Pathlike]) -> Optional[Path]:
+ """
+ Returns the first path that names no file on disk, preserving the given order.
+
+ Answers ``None`` when every path stands. The order matters to callers that report
+ one missing location among several.
+
+ Args:
+ paths: The paths to check, in report order.
+
+ Returns:
+ Optional[Path]: The first absent path, or ``None`` when every path stands.
+ """
+ for path in paths:
+ normalized = to_path(path)
+ if not normalized.exists():
+ return normalized
+
+ return None
+
+
def open_directory_in_explorer_linux(path: Path) -> None:
"""
Opens a directory in the default Linux file manager using xdg-open.
diff --git a/src/sampletones_shared/utils/system/programs.py b/src/sampletones_shared/utils/system/programs.py
new file mode 100644
index 000000000..52e39136c
--- /dev/null
+++ b/src/sampletones_shared/utils/system/programs.py
@@ -0,0 +1,46 @@
+import shutil
+from pathlib import Path
+from typing import Mapping, Optional
+
+from .system import System
+
+
+def locate_program(program: str) -> Optional[Path]:
+ """Where a program is installed, found the way a shell finds it.
+
+ Args:
+ program: The program's name, as it answers on the command line.
+
+ Returns:
+ Optional[Path]: The program's location, and None where this system carries none.
+ """
+ located = shutil.which(program)
+ if located is None:
+ return None
+
+ return Path(located)
+
+
+def missing_program_message(
+ program: str,
+ purpose: str,
+ hints: Mapping[System, str],
+) -> str:
+ """What to report when a program the project reaches for is absent.
+
+ Each supported system states its own way of installing the program, so the message names the
+ command this one is served by.
+
+ Args:
+ program: The program's name, as it answers on the command line.
+ purpose: What the project runs the program for.
+ hints: How each supported system installs it.
+
+ Returns:
+ str: The message, naming the program, what it is for and how this system installs it.
+
+ Raises:
+ OSError: If the system is unsupported.
+ KeyError: If no hint is stated for this system.
+ """
+ return f"{program} is missing: {purpose} ({hints[System.current()]})"
diff --git a/src/sampletones_shared/utils/system/reveal/__init__.py b/src/sampletones_shared/utils/system/reveal/__init__.py
new file mode 100644
index 000000000..afb7a9220
--- /dev/null
+++ b/src/sampletones_shared/utils/system/reveal/__init__.py
@@ -0,0 +1,5 @@
+from .selection import open_paths_in_explorer
+
+__all__ = [
+ "open_paths_in_explorer",
+]
diff --git a/src/sampletones_shared/utils/system/reveal/file_manager1.py b/src/sampletones_shared/utils/system/reveal/file_manager1.py
new file mode 100644
index 000000000..5cd7077f3
--- /dev/null
+++ b/src/sampletones_shared/utils/system/reveal/file_manager1.py
@@ -0,0 +1,79 @@
+from pathlib import Path
+from typing import Final, List, Tuple, Type
+
+from jeepney import (
+ AuthenticationError,
+ DBusAddress,
+ DBusErrorResponse,
+ new_method_call,
+)
+from jeepney.io.blocking import open_dbus_connection
+
+FILE_MANAGER_BUS_NAME: Final[str] = "org.freedesktop.FileManager1"
+FILE_MANAGER_OBJECT_PATH: Final[str] = "/org/freedesktop/FileManager1"
+FILE_MANAGER_INTERFACE: Final[str] = "org.freedesktop.FileManager1"
+SHOW_ITEMS_METHOD: Final[str] = "ShowItems"
+SHOW_ITEMS_SIGNATURE: Final[str] = "ass"
+EMPTY_STARTUP_ID: Final[str] = ""
+SESSION_BUS: Final[str] = "SESSION"
+
+FILE_MANAGER_ADDRESS: Final[DBusAddress] = DBusAddress(
+ FILE_MANAGER_OBJECT_PATH,
+ bus_name=FILE_MANAGER_BUS_NAME,
+ interface=FILE_MANAGER_INTERFACE,
+)
+
+OUT_OF_REACH_ERRORS: Final[Tuple[Type[Exception], ...]] = (
+ KeyError,
+ RuntimeError,
+ OSError,
+ AuthenticationError,
+ DBusErrorResponse,
+)
+
+
+class FileManager1Backend:
+ """
+ Reveals files through the desktop's ``org.freedesktop.FileManager1`` service.
+
+ One ``ShowItems`` call opens a single file-manager window with every file selected,
+ whichever directories the files live in. The method takes the file URIs followed by a
+ startup id; an empty startup id marks the call as independent of any launch context,
+ which file managers accept.
+ """
+
+ def open(self, paths: Tuple[Path, ...]) -> None:
+ uris: List[str] = [path.as_uri() for path in paths]
+ with open_dbus_connection(bus=SESSION_BUS) as connection:
+ connection.send_and_get_reply(
+ new_method_call(
+ FILE_MANAGER_ADDRESS,
+ SHOW_ITEMS_METHOD,
+ SHOW_ITEMS_SIGNATURE,
+ (uris, EMPTY_STARTUP_ID),
+ )
+ )
+
+ @classmethod
+ def answers(cls) -> bool:
+ """
+ Reports whether the service answers a probe call on the session bus.
+
+ The probe sends ``ShowItems`` with an empty URI list, which the service answers once
+ it accepts the interface. ``open_dbus_connection`` opens the session bus directly, so
+ the probe reaches the same service :meth:`open` uses.
+ """
+ try:
+ with open_dbus_connection(bus=SESSION_BUS) as connection:
+ connection.send_and_get_reply(
+ new_method_call(
+ FILE_MANAGER_ADDRESS,
+ SHOW_ITEMS_METHOD,
+ SHOW_ITEMS_SIGNATURE,
+ ([], EMPTY_STARTUP_ID),
+ )
+ )
+ except OUT_OF_REACH_ERRORS:
+ return False
+
+ return True
diff --git a/src/sampletones_shared/utils/system/reveal/grouped.py b/src/sampletones_shared/utils/system/reveal/grouped.py
new file mode 100644
index 000000000..9dd7f7946
--- /dev/null
+++ b/src/sampletones_shared/utils/system/reveal/grouped.py
@@ -0,0 +1,24 @@
+from pathlib import Path
+from typing import Tuple
+
+from sampletones_shared.utils.system.paths import open_path_in_explorer
+
+
+def distinct_parents(paths: Tuple[Path, ...]) -> Tuple[Path, ...]:
+ """
+ Returns the directories holding the paths, each once and in first-seen order.
+ """
+ return tuple(dict.fromkeys(path.parent for path in paths))
+
+
+class GroupedDirectoryBackend:
+ """
+ Reveals files by opening the directories that hold them.
+
+ Files sharing a directory are revealed together in that directory's window, one window
+ per directory, which suits file managers that open directories.
+ """
+
+ def open(self, paths: Tuple[Path, ...]) -> None:
+ for directory in distinct_parents(paths):
+ open_path_in_explorer(directory)
diff --git a/src/sampletones_shared/utils/system/reveal/protocol.py b/src/sampletones_shared/utils/system/reveal/protocol.py
new file mode 100644
index 000000000..b66306e97
--- /dev/null
+++ b/src/sampletones_shared/utils/system/reveal/protocol.py
@@ -0,0 +1,13 @@
+from pathlib import Path
+from typing import Protocol, Tuple
+
+
+class RevealBackend(Protocol):
+ """The file-revealing surface that callers depend on.
+
+ Every implementation opens the desktop's file manager on the given files, so callers
+ reveal a set of paths through this type and stay independent of the environment that
+ draws the windows.
+ """
+
+ def open(self, paths: Tuple[Path, ...]) -> None: ...
diff --git a/src/sampletones_shared/utils/system/reveal/selection.py b/src/sampletones_shared/utils/system/reveal/selection.py
new file mode 100644
index 000000000..ed9abc224
--- /dev/null
+++ b/src/sampletones_shared/utils/system/reveal/selection.py
@@ -0,0 +1,53 @@
+from typing import Sequence
+
+from sampletones_shared.types.path import Pathlike
+from sampletones_shared.utils.system.modules import JEEPNEY_MODULE, module_available
+from sampletones_shared.utils.system.paths import open_path_in_explorer, to_path
+from sampletones_shared.utils.system.system import System
+
+from .grouped import GroupedDirectoryBackend
+from .protocol import RevealBackend
+
+
+def open_paths_in_explorer(paths: Sequence[Pathlike]) -> None:
+ """
+ Reveals the given paths in the system's default file explorer.
+
+ A single path opens the explorer on it the way :func:`open_path_in_explorer` does. Several
+ paths go through the environment's reveal backend: a Linux session whose file manager
+ offers ``org.freedesktop.FileManager1`` opens one window with every file selected, and any
+ other environment opens one window per directory holding the files.
+
+ Args:
+ paths: The file paths to reveal.
+
+ Raises:
+ ValueError: If at least one path is required.
+ """
+ normalized = tuple(to_path(path) for path in paths)
+ if not normalized:
+ raise ValueError("At least one path is required")
+
+ if len(normalized) == 1:
+ open_path_in_explorer(normalized[0])
+ return
+
+ select_reveal_backend().open(normalized)
+
+
+def select_reveal_backend() -> RevealBackend:
+ """
+ Returns the reveal backend that fits the running environment.
+
+ Linux sessions whose file manager answers the ``FileManager1`` probe reveal every path in
+ one window with all of them selected; every other environment reveals one window per
+ directory holding the paths. The service is probed at selection time, so the grouped
+ backend serves sessions where it is absent.
+ """
+ if System.current() == System.LINUX and module_available(JEEPNEY_MODULE):
+ from .file_manager1 import FileManager1Backend
+
+ if FileManager1Backend.answers():
+ return FileManager1Backend()
+
+ return GroupedDirectoryBackend()
diff --git a/src/sampletones_synthesis/frequency.py b/src/sampletones_synthesis/frequency.py
index 36f46d085..9ab12a4df 100644
--- a/src/sampletones_synthesis/frequency.py
+++ b/src/sampletones_synthesis/frequency.py
@@ -2,8 +2,8 @@
from pydantic import BeforeValidator, Field, StrictFloat, StrictInt
-from sampletones_core.constants.general import LIMIT_MAX_PITCH, LIMIT_MIN_PITCH
-from sampletones_core.utils.frequencies import pitch_to_frequency
+from sampletones_shared.constants.music import LIMIT_MAX_PITCH, LIMIT_MIN_PITCH
+from sampletones_shared.utils.frequencies import pitch_to_frequency
def _require_hertz(value: Any) -> Any:
diff --git a/stubs/anytree/__init__.pyi b/stubs/anytree/__init__.pyi
new file mode 100644
index 000000000..59e786c8f
--- /dev/null
+++ b/stubs/anytree/__init__.pyi
@@ -0,0 +1,55 @@
+from typing import Any, Callable, Iterator, Optional, Tuple
+
+class NodeMixin:
+ separator: str
+
+ @property
+ def parent(self) -> Any: ...
+ @parent.setter
+ def parent(self, value: Any) -> None: ...
+ @property
+ def children(self) -> Tuple[Any, ...]: ...
+ @children.setter
+ def children(self, value: Any) -> None: ...
+ @property
+ def path(self) -> Tuple[Any, ...]: ...
+ @property
+ def root(self) -> Any: ...
+ @property
+ def ancestors(self) -> Tuple[Any, ...]: ...
+ @property
+ def descendants(self) -> Tuple[Any, ...]: ...
+ @property
+ def siblings(self) -> Tuple[Any, ...]: ...
+ @property
+ def leaves(self) -> Tuple[Any, ...]: ...
+ @property
+ def is_leaf(self) -> bool: ...
+ @property
+ def is_root(self) -> bool: ...
+ @property
+ def height(self) -> int: ...
+ @property
+ def depth(self) -> int: ...
+ def iter_path_reverse(self) -> Iterator[Any]: ...
+
+class Node(NodeMixin):
+ name: Any
+
+ def __init__(
+ self,
+ name: Any,
+ parent: Optional[Any] = ...,
+ children: Optional[Any] = ...,
+ **kwargs: Any,
+ ) -> None: ...
+
+class PreOrderIter:
+ def __init__(
+ self,
+ node: NodeMixin,
+ filter_: Optional[Callable[[Any], bool]] = ...,
+ stop: Optional[Callable[[Any], bool]] = ...,
+ maxlevel: Optional[int] = ...,
+ ) -> None: ...
+ def __iter__(self) -> Iterator[Any]: ...
diff --git a/tests/benchmarks/__init__.py b/tests/benchmarks/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/tests/benchmarks/conftest.py b/tests/benchmarks/conftest.py
new file mode 100644
index 000000000..3c2949f8d
--- /dev/null
+++ b/tests/benchmarks/conftest.py
@@ -0,0 +1,15 @@
+from tests.integration.conftest import (
+ audio_directory,
+ instrument_catalog,
+ integration_project,
+ module_config,
+ synth_config,
+)
+
+__all__ = [
+ "audio_directory",
+ "instrument_catalog",
+ "integration_project",
+ "module_config",
+ "synth_config",
+]
diff --git a/tests/benchmarks/test_compression.py b/tests/benchmarks/test_compression.py
new file mode 100644
index 000000000..083074ef8
--- /dev/null
+++ b/tests/benchmarks/test_compression.py
@@ -0,0 +1,69 @@
+from time import process_time
+from typing import Final
+
+import pytest
+
+from sampletones_core.project.project import Project
+from sampletones_player.compression.encode import encode_planes
+from sampletones_player.compression.options import EVERY_LAYER
+from tests.integration.nsf.corpus import (
+ LONG_ARRANGEMENT,
+ RECONSTRUCTION,
+ RECONSTRUCTION_SECONDS,
+ TARGET_SECONDS,
+ CorpusEntry,
+ arrangement_entry,
+ lengthened_arrangement,
+ reconstruction_entry,
+)
+
+MAX_ENCODER_SECONDS: Final[float] = 30.0
+
+
+@pytest.fixture(scope="module")
+def long_arrangement(integration_project: Project) -> CorpusEntry:
+ """The three-minute song an export is measured against."""
+ return arrangement_entry(
+ LONG_ARRANGEMENT,
+ lengthened_arrangement(integration_project, TARGET_SECONDS),
+ )
+
+
+@pytest.fixture(scope="module")
+def dense_reconstruction() -> CorpusEntry:
+ """The minute of reconstructed audio the encoder works hardest on."""
+ return reconstruction_entry(RECONSTRUCTION, RECONSTRUCTION_SECONDS)
+
+
+class TestTheEncoderKeepsWithinWhatAnExportAllows:
+ """The codec runs while the user waits for the file, so its cost is held to a bound."""
+
+ def test_a_three_minute_song_encodes_within_the_budget(
+ self,
+ long_arrangement: CorpusEntry,
+ ) -> None:
+ """The bound stands where an export would keep the user waiting, well above today's reading."""
+ planes = long_arrangement.planes
+ started = process_time()
+ encode_planes(
+ planes,
+ long_arrangement.seeds,
+ options=EVERY_LAYER,
+ boundaries=frozenset(),
+ )
+ assert process_time() - started < MAX_ENCODER_SECONDS
+
+ def test_a_reconstruction_encodes_within_the_budget(
+ self,
+ dense_reconstruction: CorpusEntry,
+ ) -> None:
+ """A song offering no phrases leans wholly on the search, which is where the cost is."""
+ planes = dense_reconstruction.planes
+ started = process_time()
+ encode_planes(
+ planes,
+ dense_reconstruction.seeds,
+ options=EVERY_LAYER,
+ boundaries=frozenset(),
+ )
+ assert process_time() - started < MAX_ENCODER_SECONDS
diff --git a/tests/benchmarks/test_pitch_bend.py b/tests/benchmarks/test_pitch_bend.py
new file mode 100644
index 000000000..e53e73a15
--- /dev/null
+++ b/tests/benchmarks/test_pitch_bend.py
@@ -0,0 +1,60 @@
+from time import process_time
+from typing import Final, List
+
+import pytest
+
+from sampletones_core.configs import Config
+from sampletones_core.constants.enums import ChannelName
+from sampletones_core.generators.render import render_instructions
+from sampletones_core.instructions import PulseInstruction
+
+FRAMES: Final[int] = 6000
+PITCH: Final[int] = 60
+VOLUME: Final[int] = 12
+REPEATS: Final[int] = 3
+BEND_OVERHEAD_LIMIT: Final[float] = 1.25
+
+
+def _stream(bent: bool) -> List[PulseInstruction]:
+ """One channel's frames, every one of them bent or none of them."""
+ return [
+ PulseInstruction(
+ on=True,
+ pitch=PITCH,
+ volume=VOLUME,
+ duty_cycle=frame % 4,
+ detune=(frame % 21) - 10 if bent else 0,
+ )
+ for frame in range(FRAMES)
+ ]
+
+
+def _render_seconds(config: Config, instructions: List[PulseInstruction]) -> float:
+ """The best of several renders, which is the reading least disturbed by other load."""
+ readings: List[float] = []
+ for _ in range(REPEATS):
+ started = process_time()
+ render_instructions(instructions, ChannelName.PULSE1, config)
+ readings.append(process_time() - started)
+
+ return min(readings)
+
+
+@pytest.fixture(scope="module")
+def config() -> Config:
+ return Config()
+
+
+class TestBendingCostsNothingToRender:
+ """A bend moves the divider a frame loads, which is the same work as loading it unbent.
+
+ The reading is a ratio against the same render without a bend, since what a machine renders
+ a frame in is its own. What the bound catches is a bend that made rendering a different
+ kind of work — a per-frame table rebuild, a lost cache, a fallback path.
+ """
+
+ def test_a_bent_stream_renders_in_what_an_unbent_one_takes(self, config: Config) -> None:
+ unbent = _render_seconds(config, _stream(bent=False))
+ bent = _render_seconds(config, _stream(bent=True))
+
+ assert bent < unbent * BEND_OVERHEAD_LIMIT, f"unbent {unbent:.4f}s, bent {bent:.4f}s"
diff --git a/tests/conftest.py b/tests/conftest.py
index 49d3471d1..910b7167d 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -3,7 +3,7 @@
import pytest
from sampletones_application.utils.gui.palette.palette import PaletteBindings
-from sampletones_core.constants.enums import GeneratorName
+from sampletones_core.constants.enums import ChannelName
from sampletones_core.reconstructions import Reconstruction
from tests.suite.sequencer import sample_reconstruction
@@ -26,6 +26,6 @@ def palette_bindings() -> Iterator[None]:
@pytest.fixture
def reconstruction_factory() -> ReconstructionFactory:
def build() -> Reconstruction:
- return sample_reconstruction([GeneratorName.PULSE1])
+ return sample_reconstruction([ChannelName.PULSE1])
return build
diff --git a/tests/integration/assets/reconstruction.py b/tests/integration/assets/reconstruction.py
index 662e9a07e..0ec1ed42d 100644
--- a/tests/integration/assets/reconstruction.py
+++ b/tests/integration/assets/reconstruction.py
@@ -1,5 +1,5 @@
from pathlib import Path
-from typing import Any, Dict, Final, FrozenSet, List
+from typing import Any, Dict, Final, FrozenSet, List, Tuple
import numpy as np
@@ -7,46 +7,113 @@
from sampletones_core.audio.processing import normalize
from sampletones_core.configs import Config, InstructionsLibraryConfig
from sampletones_core.configs.generation import GenerationConfig
-from sampletones_core.constants.enums import GeneratorName, SpectrumMethod
+from sampletones_core.constants.enums import ChannelName, HierarchyMode, SpectrumMethod
from sampletones_core.fft import Window
from sampletones_core.fft.features import get_feature_extractor
-from sampletones_core.generators import get_generators_by_names
+from sampletones_core.generators import get_generators_by_channels
from sampletones_core.instructions import InstructionUnion
from sampletones_core.library import (
InstructionLibrary,
InstructionLibraryData,
InstructionLibraryFragment,
)
-from sampletones_core.project.instruments.sample import Sample
+from sampletones_core.project.voices.sample import Sample
from sampletones_core.reconstructions import Reconstruction, Reconstructor
+from sampletones_core.reconstructions.reconstructor.stems.configs.config import StemsConfig
+from sampletones_core.reconstructions.reconstructor.stems.configs.entry import StemEntry
+from sampletones_core.reconstructions.reconstructor.stems.configs.hierarchy import StemsHierarchy
from sampletones_shared.types.path import Pathlike
from sampletones_shared.utils.serialization import load_yaml
from tests.integration.assets.synth_config import SynthConfig
INSTRUCTIONS_PER_GENERATOR: Final[int] = 48
-LIBRARY_GENERATORS: Final[List[GeneratorName]] = [
- GeneratorName.PULSE1,
- GeneratorName.TRIANGLE,
- GeneratorName.NOISE,
+CHANNELS: Final[List[ChannelName]] = [
+ ChannelName.PULSE1,
+ ChannelName.TRIANGLE,
+ ChannelName.NOISE,
]
+STEM_A_ID: Final[int] = 0
+STEM_B_ID: Final[int] = 1
+STEM_C_ID: Final[int] = 2
+THREE_STEM_CHANNELS: Final[List[ChannelName]] = [
+ ChannelName.PULSE1,
+ ChannelName.PULSE2,
+ ChannelName.TRIANGLE,
+ ChannelName.NOISE,
+]
+THREE_STEM_ENTRY_CHANNELS: Final[Dict[int, List[ChannelName]]] = {
+ STEM_A_ID: [ChannelName.PULSE1, ChannelName.TRIANGLE, ChannelName.NOISE],
+ STEM_B_ID: [ChannelName.PULSE2, ChannelName.TRIANGLE],
+ STEM_C_ID: [ChannelName.PULSE1, ChannelName.NOISE],
+}
+STEM_RECORDING_DURATION_SECONDS: Final[float] = 0.5
+
+
+def three_stem_config() -> StemsConfig:
+ """Builds the three-stem setup the stems tests share.
+
+ Stems a (pulse 1, triangle, noise) and b (pulse 2, triangle) pick on the first
+ hierarchy level, stem c (pulse 1, noise) on the second.
+ """
+ return StemsConfig(
+ entries=[StemEntry(id=stem_id, channels=channels) for stem_id, channels in THREE_STEM_ENTRY_CHANNELS.items()],
+ hierarchy=StemsHierarchy(
+ levels=[[STEM_A_ID, STEM_B_ID], [STEM_C_ID]],
+ mode=HierarchyMode.STRICT,
+ ),
+ channel_cap=1,
+ )
+
-def build_mini_library(config: Config, *, per_generator: int = INSTRUCTIONS_PER_GENERATOR) -> InstructionLibrary:
+def three_stem_reconstruction_config() -> Config:
+ """Builds a reconstruction config with both pulses enabled for the three-stem example."""
+ return Config(generation=GenerationConfig(channels=THREE_STEM_CHANNELS))
+
+
+def write_three_stem_recordings(
+ config: Config,
+ tmp_dir: Pathlike,
+) -> Tuple[Path, Path, Path]:
+ """Writes three distinct stem recordings a, b, c and returns their paths in order."""
+ sample_rate = config.library.sample_rate
+ count = int(sample_rate * STEM_RECORDING_DURATION_SECONDS)
+ time = np.arange(count) / sample_rate
+ recordings = {
+ "a": 0.5 * np.sin(2 * np.pi * 440.0 * time),
+ "b": 0.4 * np.sin(2 * np.pi * 220.0 * time),
+ "c": np.random.default_rng(93).uniform(-0.3, 0.3, count),
+ }
+
+ paths: List[Path] = []
+ for name, audio in recordings.items():
+ path = Path(tmp_dir) / f"stem_{name}.wav"
+ write_wave(path, sample_rate, audio)
+ paths.append(path)
+
+ return paths[0], paths[1], paths[2]
+
+
+def build_mini_library(
+ config: Config,
+ *,
+ per_generator: int = INSTRUCTIONS_PER_GENERATOR,
+) -> InstructionLibrary:
"""Builds a small in-memory instruction library covering pulse/triangle/noise.
- Candidates are sampled with an even stride across each generator's instruction
+ Candidates are sampled with an even stride across each channel's instruction
space so pitch, volume and period are represented, rather than a biased prefix.
"""
window = Window.from_config(config)
extractor = get_feature_extractor(config, window)
- generators = get_generators_by_names(config, LIBRARY_GENERATORS)
+ channels = get_generators_by_channels(config, CHANNELS)
data: Dict[InstructionUnion, InstructionLibraryFragment[Any]] = {}
- for generator in generators.values():
- candidates = list(generator.get_possible_instructions())
+ for channel in channels.values():
+ candidates = list(channel.get_possible_instructions())
stride = max(1, len(candidates) // per_generator)
for instruction in candidates[::stride][:per_generator]:
- data[instruction] = InstructionLibraryFragment.create(generator, instruction, extractor)
+ data[instruction] = InstructionLibraryFragment.create(channel, instruction, extractor)
library = InstructionLibrary()
library.data[library.create_key(config, window)] = InstructionLibraryData.create(config, data)
@@ -78,16 +145,16 @@ def make_sample(
library: InstructionLibrary,
*,
tmp_dir: Pathlike,
- expected_slices: FrozenSet[GeneratorName],
+ expected_slices: FrozenSet[ChannelName],
loop: bool = False,
) -> Sample:
"""Reconstructs ``audio`` into a `Sample`, asserting the channels it plays."""
reconstruction = reconstruct_sample(audio, config, library, tmp_dir=tmp_dir, name=name)
- played = frozenset(reconstruction.playing_generators)
+ played = frozenset(reconstruction.playing_channels)
if played != expected_slices:
raise AssertionError(f"Sample '{name}' covers {set(played)}, expected {set(expected_slices)}")
- return Sample(name=name, reconstruction=reconstruction, loop=loop)
+ return Sample(name=name, reconstruction=reconstruction)
def load_instrument_catalog(
@@ -117,8 +184,8 @@ def load_instrument_catalog(
catalog: Dict[str, Sample] = {}
for entry in spec["instruments"]:
- generators = [GeneratorName(name) for name in entry["generators"]]
- config = Config(library=library_config, generation=GenerationConfig(generators=generators))
+ channels = [ChannelName(name) for name in entry["channels"]]
+ config = Config(library=library_config, generation=GenerationConfig(channels=channels))
audio = _render_instrument(synth_config, entry["synth"], sample_rate=sample_rate)
catalog[entry["name"]] = make_sample(
entry["name"],
@@ -126,7 +193,7 @@ def load_instrument_catalog(
config,
library,
tmp_dir=tmp_dir,
- expected_slices=frozenset(generators),
+ expected_slices=frozenset(channels),
)
return catalog
@@ -141,7 +208,7 @@ def _render_instrument(
"""
Render a named voice at peak level 1.0.
- A fresh generator seeded from the synth configuration keeps every instrument
+ A fresh channel seeded from the synth configuration keeps every instrument
reproducible independently of catalog order.
"""
voice = synth_config.voices[name]
diff --git a/tests/integration/assets/song_loader.py b/tests/integration/assets/song_loader.py
index 03079cdc5..e8f48294c 100644
--- a/tests/integration/assets/song_loader.py
+++ b/tests/integration/assets/song_loader.py
@@ -1,13 +1,13 @@
from typing import Any, Dict, List, Mapping, Optional
-from sampletones_core.constants.enums import GeneratorName
-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.constants.enums import ChannelName
from sampletones_core.project.patterns.channel import Channel
from sampletones_core.project.patterns.pattern import Pattern
from sampletones_core.project.patterns.row import Row
from sampletones_core.project.song import Song
+from sampletones_core.project.voices.note_off import NoteOff
+from sampletones_core.project.voices.note_on import NoteOn
+from sampletones_core.project.voices.sample import Sample
from sampletones_shared.types.path import Pathlike
from sampletones_shared.utils.serialization import load_yaml
@@ -16,15 +16,15 @@
def _order(
order_specs: List[Dict[str, int]],
-) -> List[Dict[GeneratorName, Optional[int]]]:
- frames: List[Dict[GeneratorName, Optional[int]]] = []
+) -> List[Dict[ChannelName, Optional[int]]]:
+ frames: List[Dict[ChannelName, Optional[int]]] = []
for spec in order_specs:
- frames.append({generator: spec.get(generator.value) for generator in GeneratorName.items()})
+ frames.append({channel: spec.get(channel.value) for channel in ChannelName.items()})
return frames
-def _row(spec: RowSpec, generator: GeneratorName, samples_by_name: Mapping[str, Sample]) -> Row:
+def _row(spec: RowSpec, channel: ChannelName, samples_by_name: Mapping[str, Sample]) -> Row:
transpose = spec.get("transpose")
volume = spec.get("volume")
@@ -36,22 +36,22 @@ def _row(spec: RowSpec, generator: GeneratorName, samples_by_name: Mapping[str,
return Row(transpose=transpose, volume=volume)
sample = samples_by_name[sample_name]
- if generator not in sample.reconstruction.instructions:
- raise ValueError(f"Sample '{sample_name}' has no '{generator.value}' slice for the {generator.value} channel")
+ if channel not in sample.reconstruction.instructions:
+ raise ValueError(f"Sample '{sample_name}' has no '{channel.value}' slice for the {channel.value} channel")
- command = Instrument(sample_id=sample.id, generator_name=generator)
+ command = NoteOn(voice_id=sample.id)
return Row(command=command, transpose=transpose, volume=volume)
def _pattern(
row_specs: List[RowSpec],
rows_per_pattern: int,
- generator: GeneratorName,
+ channel: ChannelName,
samples_by_name: Mapping[str, Sample],
) -> Pattern:
rows = [Row() for _ in range(rows_per_pattern)]
for spec in row_specs:
- rows[spec["row"]] = _row(spec, generator, samples_by_name)
+ rows[spec["row"]] = _row(spec, channel, samples_by_name)
return Pattern(rows=rows)
@@ -60,26 +60,26 @@ def _channels(
channels_spec: Dict[str, Dict[str, Any]],
rows_per_pattern: int,
samples_by_name: Mapping[str, Sample],
-) -> Dict[GeneratorName, Channel]:
- channels: Dict[GeneratorName, Channel] = {}
+) -> Dict[ChannelName, Channel]:
+ channels: Dict[ChannelName, Channel] = {}
for name, spec in channels_spec.items():
- generator = GeneratorName(name)
+ channel = ChannelName(name)
patterns = {
int(index): _pattern(
row_specs,
rows_per_pattern,
- generator,
+ channel,
samples_by_name,
)
for index, row_specs in spec["patterns"].items()
}
- channels[generator] = Channel(
- generator=generator,
+ channels[channel] = Channel(
+ name=channel,
patterns=patterns,
)
- for generator in GeneratorName.items():
- channels.setdefault(generator, Channel(generator=generator, patterns={}))
+ for channel in ChannelName.items():
+ channels.setdefault(channel, Channel(name=channel, patterns={}))
return channels
diff --git a/tests/integration/bitphase/test_btp_pipeline.py b/tests/integration/bitphase/test_btp_pipeline.py
index 6882361a6..85ad771ae 100644
--- a/tests/integration/bitphase/test_btp_pipeline.py
+++ b/tests/integration/bitphase/test_btp_pipeline.py
@@ -44,7 +44,7 @@
NoteName,
)
from sampletones_core.project.project import Project
-from sampletones_core.timing import Metre, RowRate, calculate_groove
+from sampletones_core.timing import Meter, RowRate, calculate_groove
from tests.suite.bitphase import (
BITPHASE_NO_EFFECTS,
LoadedEffect,
@@ -81,7 +81,7 @@ def at_tempo(project: Project, tempo: int) -> Project:
metadata=project.metadata,
info=project.info,
settings=project.settings.model_copy(update={"tempo": tempo}),
- samples=project.samples,
+ voices=project.voices,
song=project.song,
)
@@ -182,7 +182,7 @@ def test_every_channel_fills_its_pattern(self, document: LoadedProject) -> None:
for channel in pattern.channels
)
- def test_each_channel_is_labelled_as_its_position_names_it(self, document: LoadedProject) -> None:
+ def test_each_channel_is_labeled_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)
@@ -263,7 +263,7 @@ def test_the_table_holds_the_groove_the_project_plays(
project = at_tempo(integration_project, GROOVE_TEMPO)
groove = calculate_groove(
RowRate.from_settings(project.settings),
- Metre.from_settings(project.settings, rows=project.song.rows_per_pattern),
+ Meter.from_settings(project.settings, rows=project.song.rows_per_pattern),
minimum_ticks=MIN_INITIAL_SPEED,
maximum_ticks=MAX_INITIAL_SPEED,
)
diff --git a/tests/integration/config/reconstruction.yaml b/tests/integration/config/reconstruction.yaml
index b87f08954..70b10c633 100644
--- a/tests/integration/config/reconstruction.yaml
+++ b/tests/integration/config/reconstruction.yaml
@@ -3,6 +3,6 @@ reconstruction:
transformation_gamma: 0
instructions_per_generator: 32
instruments:
- - { name: kick, synth: kick, generators: [pulse1, triangle] }
- - { name: lead, synth: lead, generators: [pulse1, pulse2] }
- - { name: hihat, synth: hihat, generators: [noise] }
+ - { name: kick, synth: kick, channels: [pulse1, triangle] }
+ - { name: lead, synth: lead, channels: [pulse1, pulse2] }
+ - { name: hihat, synth: hihat, channels: [noise] }
diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py
index 43170357f..8d6b920c8 100644
--- a/tests/integration/conftest.py
+++ b/tests/integration/conftest.py
@@ -3,9 +3,9 @@
import pytest
-from sampletones_core.project.instruments.sample import Sample
from sampletones_core.project.project import Project
from sampletones_core.project.settings import ProjectSettings
+from sampletones_core.project.voices.sample import Sample
from sampletones_core.structures import IdentifiedCollection
from tests.integration.assets.module_config import ModuleConfig, load_module_config
from tests.integration.assets.reconstruction import load_instrument_catalog
@@ -41,9 +41,9 @@ def instrument_catalog(audio_directory: Path, synth_config: SynthConfig) -> Dict
@pytest.fixture(scope="session")
def integration_project(instrument_catalog: Dict[str, Sample], module_config: ModuleConfig) -> Project:
- samples: IdentifiedCollection[Sample] = IdentifiedCollection()
+ voices: IdentifiedCollection[Sample] = IdentifiedCollection()
for sample in instrument_catalog.values():
- samples.append(sample)
+ voices.append(sample)
settings = ProjectSettings(
tempo=module_config.tempo,
@@ -51,6 +51,6 @@ def integration_project(instrument_catalog: Dict[str, Sample], module_config: Mo
nes_frequency=module_config.nes_frequency,
)
project = Project.create(title=module_config.title, author=module_config.author, settings=settings)
- project.samples = samples
+ project.voices = voices
project.song = load_song(SONG_PATH, instrument_catalog)
return project
diff --git a/tests/integration/famitracker/test_ftm_pipeline.py b/tests/integration/famitracker/test_ftm_pipeline.py
index c9a8e90af..2d24b22b3 100644
--- a/tests/integration/famitracker/test_ftm_pipeline.py
+++ b/tests/integration/famitracker/test_ftm_pipeline.py
@@ -3,11 +3,15 @@
import pytest
from sampletones_application.logic.sequencer.channels import SequencerChannelsLogic
-from sampletones_core.constants.enums import GeneratorName
+from sampletones_core.constants.enums import ChannelName
+from sampletones_core.constants.general import SILENT_VOLUME
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.formats.famitracker.specification.sequences import (
+ NO_LOOP_POINT,
+ SequenceKind,
+)
from sampletones_core.project.project import Project
from tests.suite.famitracker import ParsedModule, parse_ftm
@@ -50,6 +54,21 @@ def test_module_carries_audible_volume(self, parsed_module: ParsedModule) -> Non
]
assert any(any(item > 0 for item in sequence.items) for sequence in volume_sequences)
+ def test_every_written_instrument_releases_its_note(self, parsed_module: ParsedModule) -> None:
+ """A halted volume sequence ends at silence, which is what stops the note it sounds.
+
+ FamiTracker holds a halted sequence's last item for as long as a row keeps the note
+ sounding, so an instrument whose volume ended audible would sound to the end of the song.
+ """
+ halting = [
+ sequence
+ for sequence in parsed_module.sequences
+ if sequence.sequence_type == int(SequenceKind.VOLUME) and sequence.loop_point == NO_LOOP_POINT
+ ]
+
+ assert halting
+ assert all(sequence.items[-1] == SILENT_VOLUME for sequence in halting)
+
class TestSilencedChannelsReachTheModuleWhole:
"""A silenced channel belongs to the listening session, so the module still carries it.
@@ -65,8 +84,8 @@ def test_a_soloed_mix_writes_every_played_channel(
module_path: Path,
) -> None:
channels = SequencerChannelsLogic()
- channels.solo(GeneratorName.TRIANGLE)
- assert channels.active_channels == frozenset({GeneratorName.TRIANGLE})
+ channels.solo(ChannelName.TRIANGLE)
+ assert channels.active_channels == frozenset({ChannelName.TRIANGLE})
write_ftm(module_path, integration_project)
diff --git a/tests/integration/nsf/__init__.py b/tests/integration/nsf/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/tests/integration/nsf/conftest.py b/tests/integration/nsf/conftest.py
new file mode 100644
index 000000000..1a2567f14
--- /dev/null
+++ b/tests/integration/nsf/conftest.py
@@ -0,0 +1,48 @@
+from pathlib import Path
+from typing import Dict, Final, Optional
+
+import pytest
+
+from sampletones_core.project.voices.sample import Sample
+from sampletones_player.builder import song_from_reconstruction
+from sampletones_player.driver.image import DriverImage
+from sampletones_player.song import Song
+from sampletones_shared.paths.extensions import EXT_FILE_NSF
+from tests.integration.output import resolve_output_directory, resolve_output_path
+from tests.integration.paths import NSF_OUTPUT_ENV
+
+EXPORTED_SAMPLE: Final[str] = "lead"
+
+
+@pytest.fixture(scope="session")
+def nsf_output_dir() -> Optional[Path]:
+ """The persistent output directory ``SAMPLETONES_NSF_OUTPUT_DIR`` names."""
+ return resolve_output_directory(NSF_OUTPUT_ENV)
+
+
+@pytest.fixture
+def nsf_paths(
+ nsf_output_dir: Optional[Path],
+ tmp_path: Path,
+ instrument_catalog: Dict[str, Sample],
+) -> Dict[str, Path]:
+ """Where each sample's produced ``.nsf`` is written."""
+ return {name: resolve_output_path(nsf_output_dir, tmp_path, f"{name}{EXT_FILE_NSF}") for name in instrument_catalog}
+
+
+@pytest.fixture(scope="session")
+def driver_image() -> DriverImage:
+ """The assembled driver every exported file carries."""
+ return DriverImage.load()
+
+
+@pytest.fixture
+def sample(instrument_catalog: Dict[str, Sample]) -> Sample:
+ """The sample the structural cases read, covering both pulse channels."""
+ return instrument_catalog[EXPORTED_SAMPLE]
+
+
+@pytest.fixture
+def song(sample: Sample) -> Song:
+ """The song the console plays that sample as."""
+ return song_from_reconstruction(sample.reconstruction, loop_tick=None)
diff --git a/tests/integration/nsf/console/__init__.py b/tests/integration/nsf/console/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/tests/integration/nsf/console/instructions.py b/tests/integration/nsf/console/instructions.py
new file mode 100644
index 000000000..c82f8bbf1
--- /dev/null
+++ b/tests/integration/nsf/console/instructions.py
@@ -0,0 +1,182 @@
+from typing import Dict, Final, List, Mapping, Tuple
+
+import numpy as np
+
+from sampletones_core.audio.mixing import mix
+from sampletones_core.constants.enums import ChannelName
+from sampletones_core.constants.general import MAX_PERIOD, MAX_VOLUME
+from sampletones_core.generators.render import render_channels
+from sampletones_core.instructions import (
+ InstructionUnion,
+ NoiseInstruction,
+ PulseInstruction,
+ TriangleInstruction,
+)
+from sampletones_core.reconstructions import Reconstruction
+from sampletones_player.registers.playable import playable
+from sampletones_player.specification.channels import CHANNEL_REGISTER_ADDRESSES
+from sampletones_player.specification.registers import (
+ DUTY_CYCLE_SHIFT,
+ NOISE_MODE_SHIFT,
+ TIMER_HIGH_SHIFT,
+ TRIANGLE_COUNTER_CONTROL,
+ TRIANGLE_SOUNDING_RELOAD,
+)
+from sampletones_player.trace.trace import RegisterTrace
+from tests.integration.nsf.console.machine import register_file
+
+TRIANGLE_SOUNDING: Final[int] = TRIANGLE_COUNTER_CONTROL | TRIANGLE_SOUNDING_RELOAD
+
+
+def channel_values(registers: Mapping[int, int], channel: ChannelName) -> Tuple[int, ...]:
+ """The values standing in one channel's registers, in the order the driver writes them."""
+ return tuple(registers[address] for address in CHANNEL_REGISTER_ADDRESSES[channel])
+
+
+def timer_value(timer_low: int, timer_high: int) -> int:
+ """The period the two halves of a channel's timer carry together."""
+ return (timer_high << TIMER_HIGH_SHIFT) | timer_low
+
+
+def pulse_instruction(
+ registers: Mapping[int, int],
+ channel: ChannelName,
+ pitches: Mapping[int, int],
+) -> PulseInstruction:
+ """The instruction a pulse channel's registers sound.
+
+ Volume rides in the control byte's low nibble and the duty cycle in its top two bits, so a
+ level of zero is what marks the tick a rest — the pitch and timbre standing there are the ones
+ the channel was holding when it stopped sounding.
+
+ Args:
+ registers: The whole register file at a tick.
+ channel: Which pulse channel to read.
+ pitches: The pitch each timer value belongs to.
+
+ Returns:
+ PulseInstruction: The frame the channel plays.
+
+ Raises:
+ KeyError: If the timer standing there belongs to no pitch the configuration covers.
+ """
+ control, timer_low, timer_high = channel_values(registers, channel)
+ volume = control & MAX_VOLUME
+ return PulseInstruction(
+ on=volume > 0,
+ pitch=pitches[timer_value(timer_low, timer_high)],
+ volume=volume,
+ duty_cycle=control >> DUTY_CYCLE_SHIFT,
+ )
+
+
+def triangle_instruction(registers: Mapping[int, int], pitches: Mapping[int, int]) -> TriangleInstruction:
+ """The instruction the triangle channel's registers sound.
+
+ The channel states whether it sounds through the linear counter's reload value, so a full
+ reload beside the control bit is the tick sounding and anything else is a rest.
+
+ Args:
+ registers: The whole register file at a tick.
+ pitches: The pitch each timer value belongs to.
+
+ Returns:
+ TriangleInstruction: The frame the channel plays.
+
+ Raises:
+ KeyError: If the timer standing there belongs to no pitch the configuration covers.
+ """
+ linear_counter, timer_low, timer_high = channel_values(registers, ChannelName.TRIANGLE)
+ return TriangleInstruction(
+ on=linear_counter == TRIANGLE_SOUNDING,
+ pitch=pitches[timer_value(timer_low, timer_high)],
+ )
+
+
+def noise_instruction(registers: Mapping[int, int]) -> NoiseInstruction:
+ """The instruction the noise channel's registers sound.
+
+ The register counts periods from the fastest and the project counts them from the slowest, so
+ the period reaches the instruction as its complement, the way the encoder wrote it.
+
+ Args:
+ registers: The whole register file at a tick.
+
+ Returns:
+ NoiseInstruction: The frame the channel plays.
+ """
+ control, period = channel_values(registers, ChannelName.NOISE)
+ volume = control & MAX_VOLUME
+ return NoiseInstruction(
+ on=volume > 0,
+ period=MAX_PERIOD - (period & MAX_PERIOD),
+ volume=volume,
+ short=bool(period >> NOISE_MODE_SHIFT),
+ )
+
+
+def instructions_at(registers: Mapping[int, int], pitches: Mapping[int, int]) -> Dict[ChannelName, InstructionUnion]:
+ """Every channel's instruction for one tick, read back out of the registers standing at it.
+
+ Args:
+ registers: The whole register file at a tick.
+ pitches: The pitch each timer value belongs to.
+
+ Returns:
+ Dict[ChannelName, InstructionUnion]: One instruction per channel.
+ """
+ return {
+ ChannelName.PULSE1: pulse_instruction(registers, ChannelName.PULSE1, pitches),
+ ChannelName.PULSE2: pulse_instruction(registers, ChannelName.PULSE2, pitches),
+ ChannelName.TRIANGLE: triangle_instruction(registers, pitches),
+ ChannelName.NOISE: noise_instruction(registers),
+ }
+
+
+def instructions_from_trace(
+ trace: RegisterTrace,
+ timer_table: Mapping[int, int],
+) -> Dict[ChannelName, List[InstructionUnion]]:
+ """The per-tick instructions a captured run plays, one stream per channel.
+
+ This closes the loop the export opens: instructions became register values, the values became
+ a file, the driver moved them to the APU, and reading them back states what the console sounds
+ in the very terms the generators render from.
+
+ Args:
+ trace: The writes a run of the driver made.
+ timer_table: The timer register value each pitch sounds at.
+
+ Returns:
+ Dict[ChannelName, List[InstructionUnion]]: Each channel's stream, one instruction per
+ tick the run sounded.
+ """
+ pitches = {timer: pitch for pitch, timer in timer_table.items()}
+ streams: Dict[ChannelName, List[InstructionUnion]] = {channel: [] for channel in ChannelName.items()}
+
+ for registers in register_file(trace):
+ for channel, instruction in instructions_at(registers, pitches).items():
+ streams[channel].append(instruction)
+
+ return streams
+
+
+def sounded_approximation(reconstruction: Reconstruction) -> np.ndarray:
+ """A reconstruction's own waveform, rendered from the frames the console can sound.
+
+ A bend has nowhere to travel in a channel's planes, so ``playable`` states each frame as the
+ driver loads it and the reconstruction is rendered from those. Holding the console's output
+ against this waveform is what makes the comparison one about the driver rather than about the
+ divider offset it has yet to gain.
+
+ Args:
+ reconstruction: The reconstruction the console is playing.
+
+ Returns:
+ np.ndarray: The waveform its playable frames sound as.
+ """
+ streams = {
+ channel_name: [playable(instruction) for instruction in instructions]
+ for channel_name, instructions in reconstruction.instructions.items()
+ }
+ return mix(list(render_channels(streams, reconstruction.config).values()))
diff --git a/tests/integration/nsf/console/machine.py b/tests/integration/nsf/console/machine.py
new file mode 100644
index 000000000..afa2a1c56
--- /dev/null
+++ b/tests/integration/nsf/console/machine.py
@@ -0,0 +1,132 @@
+from typing import Dict, Final, List, Tuple
+
+from py65.devices.mpu6502 import MPU
+from py65.memory import ObservableMemory
+
+from sampletones_player.driver.addresses import DriverAddresses
+from sampletones_player.specification.nsf import HEADER_SIZE
+from sampletones_player.specification.registers import (
+ APU_FRAME_COUNTER,
+ FIRST_CHANNEL_REGISTER,
+)
+from sampletones_player.trace.trace import RegisterTrace
+from sampletones_player.trace.write import RegisterWrite
+
+RETURN_SENTINEL: Final[int] = 0xFFF0
+STACK_PAGE: Final[int] = 0x0100
+STACK_TOP: Final[int] = 0xFF
+FIRST_SONG_INDEX: Final[int] = 0x00
+NTSC_MACHINE: Final[int] = 0x00
+STEP_BUDGET: Final[int] = 1_000_000
+
+
+class Console:
+ """A 6502 running an exported file, watching every APU register the driver writes.
+
+ An NSF player loads the image behind the header, calls the init routine once with the song
+ number in the accumulator and the machine in X, and calls the play routine at the rate the
+ header asks for. This runs the same sequence over py65's CPU with the APU's address range
+ subscribed, so each routine answers with the writes it made. Capturing writes rather than
+ sound is what lets a run stand against `RegisterTrace.from_song` value for value.
+ """
+
+ def __init__(self, data: bytes, addresses: DriverAddresses) -> None:
+ """Loads an exported file the way a player loads it.
+
+ Args:
+ data: The whole `.nsf` file, header included.
+ addresses: Where the image loads and which routines it answers at.
+ """
+ self._addresses = addresses
+ self._writes: List[RegisterWrite] = []
+ self._memory = ObservableMemory()
+ self._memory.write(addresses.load, list(data[HEADER_SIZE:]))
+ self._memory.subscribe_to_write(
+ range(FIRST_CHANNEL_REGISTER, APU_FRAME_COUNTER + 1),
+ self._observe,
+ )
+ self._processor = MPU(memory=self._memory)
+
+ def _observe(self, address: int, value: int) -> None:
+ self._writes.append(RegisterWrite(address, value))
+
+ def _seed_stack(self) -> None:
+ self._memory[STACK_PAGE + STACK_TOP] = (RETURN_SENTINEL - 1) >> 8
+ self._memory[STACK_PAGE + STACK_TOP - 1] = (RETURN_SENTINEL - 1) & 0xFF
+ self._processor.sp = STACK_TOP - 2
+
+ def _call(self, address: int, accumulator: int) -> Tuple[RegisterWrite, ...]:
+ self._writes = []
+ self._processor.a = accumulator
+ self._processor.x = NTSC_MACHINE
+ self._seed_stack()
+ self._processor.pc = address
+
+ for _ in range(STEP_BUDGET):
+ if self._processor.pc == RETURN_SENTINEL:
+ return tuple(self._writes)
+
+ self._processor.step()
+
+ raise RuntimeError(f"the routine at {address:#06x} ran for {STEP_BUDGET} instructions without returning")
+
+ def initialize(self) -> Tuple[RegisterWrite, ...]:
+ """Runs the init routine, which readies the APU and sounds the song's first tick.
+
+ Returns:
+ Tuple[RegisterWrite, ...]: Every register the routine wrote, in order.
+ """
+ return self._call(self._addresses.init, FIRST_SONG_INDEX)
+
+ def play(self) -> Tuple[RegisterWrite, ...]:
+ """Runs one play call, the way the console calls it each frame.
+
+ Returns:
+ Tuple[RegisterWrite, ...]: Every register the call wrote, empty where the streams
+ hold their tick through it.
+ """
+ return self._call(self._addresses.play, FIRST_SONG_INDEX)
+
+ def trace(self, play_calls: int) -> RegisterTrace:
+ """Runs a whole session: initialization followed by ``play_calls`` play calls.
+
+ Args:
+ play_calls: How many play calls the run covers.
+
+ Returns:
+ RegisterTrace: The writes the driver made, grouped the way the model states them.
+ """
+ initialization = self.initialize()
+ return RegisterTrace(
+ initialization=initialization,
+ play_calls=tuple(self.play() for _ in range(play_calls)),
+ )
+
+
+def register_file(trace: RegisterTrace) -> List[Dict[int, int]]:
+ """The APU as the driver leaves it after initialization and after every call that sounds.
+
+ A tick reaches the hardware as the values standing in the registers once its writes land, and
+ the three registers written only on change keep the value an earlier tick left there. Reading
+ the whole file back after each sounding call is therefore what recovers a tick's full state
+ from a trace that states only what changed.
+
+ Args:
+ trace: The writes a run of the driver made.
+
+ Returns:
+ List[Dict[int, int]]: One register file per tick the run sounded, in order.
+ """
+ registers: Dict[int, int] = {}
+ ticks: List[Dict[int, int]] = []
+
+ for writes in (trace.initialization, *trace.play_calls):
+ if not writes:
+ continue
+
+ for write in writes:
+ registers[write.address] = write.value
+
+ ticks.append(dict(registers))
+
+ return ticks
diff --git a/tests/integration/nsf/console/session.py b/tests/integration/nsf/console/session.py
new file mode 100644
index 000000000..356116ee6
--- /dev/null
+++ b/tests/integration/nsf/console/session.py
@@ -0,0 +1,116 @@
+from typing import Final
+
+from sampletones_player.driver.image import DriverImage
+from sampletones_player.nsf.file import nsf_to_bytes
+from sampletones_player.nsf.information import NSFInformation
+from sampletones_player.song import Song
+from sampletones_player.trace.trace import RegisterTrace
+from tests.integration.nsf.console.machine import Console
+
+TRAILING_CALLS: Final[int] = 2
+
+
+def play_calls_covering(song: Song) -> int:
+ """How many play calls carry a song from its first tick past its last.
+
+ A stream built below the hardware rate holds its tick through some calls, so the count follows
+ the song's own schedule rather than its tick count. The run reaches a few calls beyond the end
+ as well, which is where a song without a loop is seen to stop.
+
+ Args:
+ song: The song the driver plays.
+
+ Returns:
+ int: The number of play calls the run covers.
+
+ Raises:
+ ValueError: If the song repeats, which leaves it no last tick to reach past. A run over
+ one covers the calls :func:`play_calls_reaching` measures.
+ """
+ if song.loop_tick is not None:
+ raise ValueError(f"a song repeating from tick {song.loop_tick} runs for as long as it is called")
+
+ calls = 0
+ while song.tick_at(calls) is not None:
+ calls += 1
+
+ return calls + TRAILING_CALLS
+
+
+def play_calls_reaching(song: Song, ticks: int) -> int:
+ """How many play calls carry a song's streams past ``ticks`` of its own time.
+
+ A song that repeats runs on for as long as it is called, so a run over one is measured by the
+ ticks it is to cover rather than by where the streams end.
+
+ Args:
+ song: The song the driver plays.
+ ticks: The ticks the run is to reach past.
+
+ Returns:
+ int: The number of play calls the run covers.
+ """
+ calls = 0
+ while song.schedule.ticks_at(calls) <= ticks:
+ calls += 1
+
+ return calls
+
+
+def captured_file_trace(data: bytes, song: Song) -> RegisterTrace:
+ """Runs an exported file on a 6502 and answers with every APU write it made.
+
+ Args:
+ data: The whole ``.nsf`` file, header included.
+ song: The song the file plays, which states how far the run reaches.
+
+ Returns:
+ RegisterTrace: The writes of the initialization and of every play call in the run.
+ """
+ return captured_run(data, play_calls_covering(song))
+
+
+def captured_run(data: bytes, play_calls: int) -> RegisterTrace:
+ """Runs an exported file for a stated number of calls and answers with every APU write.
+
+ Args:
+ data: The whole ``.nsf`` file, header included.
+ play_calls: How many play calls the run covers.
+
+ Returns:
+ RegisterTrace: The writes of the initialization and of every play call in the run.
+ """
+ image = DriverImage.load()
+ console = Console(data, image.addresses)
+ return console.trace(play_calls)
+
+
+def captured_trace(song: Song, information: NSFInformation) -> RegisterTrace:
+ """Exports a song, runs the file on a 6502 and answers with every APU write it made.
+
+ Args:
+ song: The song to export and play, which states how far the run reaches.
+ information: The text the exported header carries.
+
+ Returns:
+ RegisterTrace: The writes of the initialization and of every play call in the run.
+ """
+ return captured_file_trace(nsf_to_bytes(song, information, DriverImage.load()), song)
+
+
+def captured_trace_over(
+ song: Song,
+ information: NSFInformation,
+ play_calls: int,
+) -> RegisterTrace:
+ """Exports a song and runs the file for ``play_calls`` calls, a repeating song included.
+
+ Args:
+ song: The song to export and play.
+ information: The text the exported header carries.
+ play_calls: How many play calls the run covers.
+
+ Returns:
+ RegisterTrace: The writes of the initialization and of every play call in the run.
+ """
+ return captured_run(nsf_to_bytes(song, information, DriverImage.load()), play_calls)
diff --git a/tests/integration/nsf/corpus.py b/tests/integration/nsf/corpus.py
new file mode 100644
index 000000000..db7d107d7
--- /dev/null
+++ b/tests/integration/nsf/corpus.py
@@ -0,0 +1,245 @@
+from dataclasses import dataclass
+from math import ceil
+from random import Random
+from typing import Dict, Final, List, Tuple
+
+from sampletones_core.constants.enums import ChannelName
+from sampletones_core.constants.general import MAX_DUTY_CYCLE, MAX_PERIOD, MAX_VOLUME
+from sampletones_core.instructions import (
+ InstructionUnion,
+ NoiseInstruction,
+ PulseInstruction,
+ TriangleInstruction,
+)
+from sampletones_core.project.project import Project
+from sampletones_core.project.settings import ProjectSettings
+from sampletones_core.project.tuning import tuning_from_project
+from sampletones_core.project.voices.sample import Sample
+from sampletones_core.timers.utils import get_timer_table
+from sampletones_core.timing import SongTiming
+from sampletones_player.builder import (
+ song_from_project,
+ song_from_reconstruction,
+ streams_from_instructions,
+)
+from sampletones_player.clock.schedule import PlaySchedule
+from sampletones_player.compression.dictionary.phrase import Phrase
+from sampletones_player.compression.pitch import PitchTable
+from sampletones_player.compression.planes.separate import planes_from_streams
+from sampletones_player.compression.planes.song import SongPlanes
+from sampletones_player.compression.seeds import phrases_from_project
+from sampletones_player.song import Song
+from sampletones_shared.music import Tuning
+from tests.integration.nsf.songs import RECORD_BYTES_PER_TICK, lengthened
+
+ARRANGEMENT: Final[str] = "arrangement"
+LONG_ARRANGEMENT: Final[str] = "arrangement, three minutes"
+TARGET_SECONDS: Final[int] = 180
+RECONSTRUCTION: Final[str] = "reconstruction, one minute"
+RECONSTRUCTION_SECONDS: Final[int] = 60
+RECONSTRUCTION_FREQUENCY: Final[int] = 60
+RECONSTRUCTION_SEED: Final[int] = 20260822
+LOWEST_SOUNDED_PITCH: Final[int] = 40
+HIGHEST_SOUNDED_PITCH: Final[int] = 100
+WIDEST_PITCH_STEP: Final[int] = 2
+SOUNDING_SHARE: Final[float] = 0.8
+NO_SEEDS: Final[Tuple[Phrase, ...]] = ()
+
+
+@dataclass(frozen=True)
+class CorpusEntry:
+ """One song the codec is measured on, alongside the phrases its own instruments offer."""
+
+ name: str
+ song: Song
+ seeds: Tuple[Phrase, ...]
+ tuning: Tuning
+
+ @property
+ def pitches(self) -> PitchTable:
+ """The timer each pitch of the song sounds at."""
+ return PitchTable.from_tuning(self.tuning)
+
+ @property
+ def planes(self) -> SongPlanes:
+ """The planes the song separates into."""
+ return planes_from_streams(self.song.streams, self.pitches)
+
+ @property
+ def records(self) -> int:
+ """The bytes the song takes as one record per tick per channel."""
+ return RECORD_BYTES_PER_TICK * self.song.ticks
+
+
+def _sample_project(
+ sample: Sample,
+ settings: ProjectSettings,
+) -> Project:
+ project = Project.create(settings=settings)
+ project.voices.append(sample)
+ return project
+
+
+def sample_entries(
+ instrument_catalog: Dict[str, Sample],
+ settings: ProjectSettings,
+) -> Tuple[CorpusEntry, ...]:
+ """Each catalog sample as a song of its own, played at the tuning it was reconstructed at.
+
+ Args:
+ instrument_catalog: The samples the integration suite reads.
+ settings: The project settings a sample is seeded under.
+
+ Returns:
+ Tuple[CorpusEntry, ...]: One entry per sample, in catalog order.
+ """
+ entries: List[CorpusEntry] = []
+ for name, sample in instrument_catalog.items():
+ tuning = sample.reconstruction.config.library.tuning
+ entries.append(
+ CorpusEntry(
+ name=name,
+ song=song_from_reconstruction(sample.reconstruction, loop_tick=None),
+ seeds=phrases_from_project(_sample_project(sample, settings), tuning),
+ tuning=tuning,
+ )
+ )
+
+ return tuple(entries)
+
+
+def arrangement_entry(
+ name: str,
+ project: Project,
+) -> CorpusEntry:
+ """A whole project flattened into one song, at the tuning its samples were built at.
+
+ Args:
+ name: What the entry is called in a report.
+ project: The arrangement the song is walked from.
+
+ Returns:
+ CorpusEntry: The song and the phrases the project's instruments offer.
+ """
+ tuning = tuning_from_project(project)
+ return CorpusEntry(
+ name=name,
+ song=song_from_project(project, loop_tick=None),
+ seeds=phrases_from_project(project, tuning),
+ tuning=tuning,
+ )
+
+
+def lengthened_arrangement(
+ project: Project,
+ seconds: int,
+) -> Project:
+ """``project`` with its order repeated until the song lasts ``seconds``.
+
+ The corpus arrangement is a couple of seconds long, and what the program area is measured
+ against is a song of minutes, so the order is played through as many times as that takes.
+
+ Args:
+ project: The arrangement to repeat.
+ seconds: How long the song is to last.
+
+ Returns:
+ Project: A copy of the project, its order repeated.
+ """
+ groove = SongTiming.from_project(project).groove()
+ frames = ceil(seconds * project.settings.nes_frequency / groove.total_ticks)
+ return lengthened(project, frames)
+
+
+def _sounded_instructions(ticks: int) -> Dict[ChannelName, List[InstructionUnion]]:
+ random = Random(RECONSTRUCTION_SEED)
+ pitch = LOWEST_SOUNDED_PITCH
+ instructions: Dict[ChannelName, List[InstructionUnion]] = {}
+ for channel in (ChannelName.PULSE1, ChannelName.PULSE2):
+ sounded: List[InstructionUnion] = []
+ for _ in range(ticks):
+ pitch = _walked_pitch(random, pitch)
+ sounded.append(
+ PulseInstruction(
+ on=True,
+ pitch=pitch,
+ volume=random.randint(0, MAX_VOLUME),
+ duty_cycle=random.randint(0, MAX_DUTY_CYCLE),
+ )
+ )
+
+ instructions[channel] = sounded
+
+ triangle: List[InstructionUnion] = []
+ for _ in range(ticks):
+ pitch = _walked_pitch(random, pitch)
+ triangle.append(TriangleInstruction(on=random.random() < SOUNDING_SHARE, pitch=pitch))
+
+ instructions[ChannelName.TRIANGLE] = triangle
+ instructions[ChannelName.NOISE] = [
+ NoiseInstruction(
+ on=True,
+ period=random.randint(0, MAX_PERIOD),
+ volume=random.randint(0, MAX_VOLUME),
+ short=False,
+ )
+ for _ in range(ticks)
+ ]
+ return instructions
+
+
+def _walked_pitch(random: Random, pitch: int) -> int:
+ stepped = pitch + random.randint(-WIDEST_PITCH_STEP, WIDEST_PITCH_STEP)
+ return max(LOWEST_SOUNDED_PITCH, min(HIGHEST_SOUNDED_PITCH, stepped))
+
+
+def reconstruction_entry(name: str, seconds: int) -> CorpusEntry:
+ """A song whose channels turn over at nearly every tick, as a reconstruction of audio does.
+
+ An exported reconstruction offers the dictionary nothing, so the search fills it alone, and
+ its planes change under every tick rather than resting between rows. That is the shape the
+ encoder works hardest on, and the one a budget is worth stating against.
+
+ Args:
+ name: What the entry is called in a report.
+ seconds: How long the song is to last, at the console's own frame rate.
+
+ Returns:
+ CorpusEntry: The song, offering no phrases of its own.
+ """
+ tuning = Tuning()
+ return CorpusEntry(
+ name=name,
+ song=Song.from_streams(
+ streams=streams_from_instructions(
+ _sounded_instructions(seconds * RECONSTRUCTION_FREQUENCY),
+ get_timer_table(tuning),
+ ),
+ pitches=PitchTable.from_tuning(tuning),
+ schedule=PlaySchedule.from_parameters(RECONSTRUCTION_FREQUENCY),
+ loop_tick=None,
+ seeds=NO_SEEDS,
+ ),
+ seeds=NO_SEEDS,
+ tuning=tuning,
+ )
+
+
+def build_corpus(
+ instrument_catalog: Dict[str, Sample],
+ integration_project: Project,
+) -> Tuple[CorpusEntry, ...]:
+ """The songs the codec is measured on: each sample alone, and the arrangement at two lengths.
+
+ Args:
+ instrument_catalog: The samples the integration suite reads.
+ integration_project: The arrangement those samples are played in.
+
+ Returns:
+ Tuple[CorpusEntry, ...]: The samples first, then the arrangement, then the long one.
+ """
+ return (
+ *sample_entries(instrument_catalog, integration_project.settings),
+ arrangement_entry(ARRANGEMENT, integration_project),
+ arrangement_entry(LONG_ARRANGEMENT, lengthened_arrangement(integration_project, TARGET_SECONDS)),
+ )
diff --git a/tests/integration/nsf/exports.py b/tests/integration/nsf/exports.py
new file mode 100644
index 000000000..c7041fafb
--- /dev/null
+++ b/tests/integration/nsf/exports.py
@@ -0,0 +1,10 @@
+from typing import Final
+
+from sampletones_player.nsf.information import NSFInformation
+
+ARTIST: Final[str] = "Integration"
+
+
+def exported_information(name: str) -> NSFInformation:
+ """The header text an exported sample carries."""
+ return NSFInformation(title=name, artist=ARTIST)
diff --git a/tests/integration/nsf/report.py b/tests/integration/nsf/report.py
new file mode 100644
index 000000000..c689926fe
--- /dev/null
+++ b/tests/integration/nsf/report.py
@@ -0,0 +1,115 @@
+import csv
+from dataclasses import dataclass
+from pathlib import Path
+from typing import Final, Sequence, Tuple
+
+COLUMNS: Final[Tuple[str, ...]] = (
+ "corpus",
+ "variant",
+ "ticks",
+ "bytes",
+ "bytes per tick",
+ "ratio",
+ "ticks that fit",
+ "phrases",
+ "dictionary",
+ "seconds",
+)
+
+
+@dataclass(frozen=True)
+class ReportRow:
+ """One corpus song measured under one variant of the codec.
+
+ Attributes:
+ corpus: The song measured.
+ variant: The layers the encoding was built from.
+ ticks: The ticks the song lasts.
+ size: The bytes the song's data takes.
+ variable: The part of ``size`` that grows with the song, the fixed tables aside.
+ phrases: The phrases the dictionary holds.
+ dictionary: The bytes the dictionary takes, counted within ``size``.
+ seconds: How long the encoding took.
+ records: The bytes the same song takes as one record per tick per channel.
+ space: The program area a song is written into.
+ """
+
+ corpus: str
+ variant: str
+ ticks: int
+ size: int
+ variable: int
+ phrases: int
+ dictionary: int
+ seconds: float
+ records: int
+ space: int
+
+ @property
+ def bytes_per_tick(self) -> float:
+ """The bytes each tick of the song costs."""
+ return self.size / self.ticks
+
+ @property
+ def ratio(self) -> float:
+ """How many times smaller the song is than one record per tick per channel."""
+ return self.records / self.size
+
+ @property
+ def fitting_ticks(self) -> int:
+ """The ticks a song at this rate reaches before it fills the program area.
+
+ The pitch table and the dictionary are paid once however long the song runs, so what the
+ remaining space is measured against is the part that grows with the ticks.
+ """
+ return int((self.space - (self.size - self.variable)) * self.ticks // self.variable)
+
+ @property
+ def cells(self) -> Tuple[str, ...]:
+ """The row as the report prints it, column by column."""
+ return (
+ self.corpus,
+ self.variant,
+ f"{self.ticks}",
+ f"{self.size}",
+ f"{self.bytes_per_tick:.3f}",
+ f"{self.ratio:.2f}",
+ f"{self.fitting_ticks}",
+ f"{self.phrases}",
+ f"{self.dictionary}",
+ f"{self.seconds:.2f}",
+ )
+
+
+def write_csv(rows: Sequence[ReportRow], path: Path) -> None:
+ """Writes the measurements as a table another tool reads.
+
+ Args:
+ rows: The measurements, in the order they are reported.
+ path: Where the table is written.
+ """
+ with path.open("w", encoding="utf-8", newline="") as handle:
+ writer = csv.writer(handle)
+ writer.writerow(COLUMNS)
+ for row in rows:
+ writer.writerow(row.cells)
+
+
+def write_markdown(rows: Sequence[ReportRow], path: Path, state: int) -> None:
+ """Writes the measurements as a table a reader reads.
+
+ Args:
+ rows: The measurements, in the order they are reported.
+ path: Where the table is written.
+ state: The zero-page bytes the decoder's plane state takes.
+ """
+ lines = [
+ "# Compression report",
+ "",
+ f"Decoder state: {state} bytes of zero page.",
+ "",
+ "| " + " | ".join(COLUMNS) + " |",
+ "|" + "|".join("---" for _ in COLUMNS) + "|",
+ ]
+ lines.extend("| " + " | ".join(row.cells) + " |" for row in rows)
+ path.write_text("\n".join(lines) + "\n", encoding="utf-8")
diff --git a/tests/integration/nsf/songs.py b/tests/integration/nsf/songs.py
new file mode 100644
index 000000000..db464f778
--- /dev/null
+++ b/tests/integration/nsf/songs.py
@@ -0,0 +1,32 @@
+from typing import Final
+
+from sampletones_core.project.project import Project
+from sampletones_player.driver.image import DriverImage
+from sampletones_player.specification.nsf import PROGRAM_SIZE
+
+RECORD_BYTES_PER_TICK: Final[int] = 11
+
+
+def available_bytes(driver_image: DriverImage) -> int:
+ """The program area the song block is written into, behind the driver."""
+ return PROGRAM_SIZE - len(driver_image.code)
+
+
+def lengthened(project: Project, frames: int) -> Project:
+ """``project`` with its order repeated to ``frames`` positions, over the same samples.
+
+ The song is copied rather than edited so the session's own project keeps the arrangement
+ every other case reads.
+ """
+ longer = Project.create(
+ rows_per_pattern=project.song.rows_per_pattern,
+ settings=project.settings,
+ )
+ for sample in project.voices:
+ longer.voices.append(sample)
+
+ longer.song = project.song.model_copy(deep=True)
+ while longer.song.order_length() < frames:
+ longer.song.duplicate_frame(longer.song.order_length() - 1)
+
+ return longer
diff --git a/tests/integration/nsf/test_backend.py b/tests/integration/nsf/test_backend.py
new file mode 100644
index 000000000..a080aa36a
--- /dev/null
+++ b/tests/integration/nsf/test_backend.py
@@ -0,0 +1,265 @@
+from pathlib import Path
+from typing import Dict, Final, List, Optional
+
+import numpy as np
+import pytest
+
+from sampletones_core.audio.mixing import mix
+from sampletones_core.constants.enums import ChannelName
+from sampletones_core.exporters.naming import instrument_slice_name
+from sampletones_core.exports.request import (
+ InstrumentExport,
+ ProjectExport,
+ SampleExport,
+)
+from sampletones_core.generators.render import render_channels
+from sampletones_core.instructions import InstructionUnion
+from sampletones_core.performance import song_instructions
+from sampletones_core.project.project import Project
+from sampletones_core.project.tuning import tuning_from_project
+from sampletones_core.project.voices.sample import Sample
+from sampletones_core.timers.utils import get_timer_table
+from sampletones_player.builder import (
+ SONG_START,
+ instructions_from_instruments,
+ song_from_project,
+ song_from_sample,
+)
+from sampletones_player.export import NSFBackend
+from sampletones_player.registers.playable import playable
+from sampletones_player.song import Song
+from sampletones_player.specification.nsf import NSF_MAGIC
+from sampletones_shared.paths.extensions import EXT_FILE_NSF
+from tests.integration.nsf.console.instructions import instructions_from_trace, sounded_approximation
+from tests.integration.nsf.console.session import (
+ captured_file_trace,
+ captured_run,
+ play_calls_reaching,
+)
+from tests.integration.output import resolve_output_path
+
+ChannelInstructions = Dict[ChannelName, List[InstructionUnion]]
+
+PROJECT_ARTIFACT: Final[str] = "song"
+
+
+def resting(instruction: InstructionUnion) -> InstructionUnion:
+ """A sounding instruction as it stands, and a rest as the canonical silent one.
+
+ A stream holds a channel's pitch and timbre through a rest so the driver leaves the timer's
+ high byte alone, so what a rest carries beyond its silence is the channel's own history.
+ """
+ if instruction.on:
+ return instruction
+
+ silent: InstructionUnion = type(instruction).null_instruction()
+ return silent
+
+
+def sample_request(sample: Sample) -> SampleExport:
+ """The request the application hands a backend for one loaded reconstruction.
+
+ A reconstruction reaches an export as the envelopes each of its playing channels carries,
+ which is the same reading the instruments panel and every tracker format are given.
+ """
+ config = sample.reconstruction.config
+ features_by_channel = sample.reconstruction.export()
+
+ return SampleExport(
+ name=sample.name,
+ instruments=tuple(
+ InstrumentExport(
+ name=instrument_slice_name(sample.name, channel),
+ channel=channel,
+ features=features,
+ nes_frequency=config.nes_frequency,
+ tuning=config.tuning,
+ )
+ for channel, features in features_by_channel.items()
+ if features.has_frames
+ ),
+ nes_frequency=config.nes_frequency,
+ tuning=config.tuning,
+ )
+
+
+@pytest.fixture(scope="module")
+def backend() -> NSFBackend:
+ return NSFBackend()
+
+
+@pytest.fixture(scope="module")
+def requests(instrument_catalog: Dict[str, Sample]) -> Dict[str, SampleExport]:
+ """The export request each catalog sample reaches a backend as."""
+ return {name: sample_request(sample) for name, sample in instrument_catalog.items()}
+
+
+@pytest.fixture(scope="module")
+def exported(
+ backend: NSFBackend,
+ requests: Dict[str, SampleExport],
+ tmp_path_factory: pytest.TempPathFactory,
+) -> Dict[str, Path]:
+ """Every catalog sample written to disk through the backend the application registers."""
+ directory = tmp_path_factory.mktemp("nsf-backend")
+ paths: Dict[str, Path] = {}
+ for name, request in requests.items():
+ destination = directory / f"{name}{EXT_FILE_NSF}"
+ backend.write_sample(destination, request)
+ paths[name] = destination
+
+ return paths
+
+
+@pytest.fixture(scope="module")
+def played(
+ exported: Dict[str, Path],
+ requests: Dict[str, SampleExport],
+ instrument_catalog: Dict[str, Sample],
+) -> Dict[str, ChannelInstructions]:
+ """What the console sounds when it plays each written file, read back out of its register writes."""
+ played: Dict[str, ChannelInstructions] = {}
+ for name, destination in exported.items():
+ song = song_from_sample(requests[name])
+ trace = captured_file_trace(destination.read_bytes(), song)
+ played[name] = instructions_from_trace(
+ trace,
+ get_timer_table(instrument_catalog[name].reconstruction.config.tuning),
+ )
+
+ return played
+
+
+class TestTheBackendWritesAPlayableProgram:
+ """What reaches disk when the application exports a reconstruction to the console."""
+
+ def test_every_sample_reaches_a_file_a_player_recognizes(self, exported: Dict[str, Path]) -> None:
+ for destination in exported.values():
+ assert destination.read_bytes()[: len(NSF_MAGIC)] == NSF_MAGIC
+
+ def test_the_catalog_sounds_all_four_channels(self, played: Dict[str, ChannelInstructions]) -> None:
+ sounded = {
+ channel
+ for instructions in played.values()
+ for channel, stream in instructions.items()
+ if any(instruction.on for instruction in stream)
+ }
+ assert sounded == set(ChannelName.items())
+
+
+class TestTheConsoleSoundsTheRequest:
+ """The envelopes an export request carries, read back off the APU the file drives.
+
+ A frame reaches the console through ``playable``, which states what its planes can carry of
+ one, so both sides are read in the terms the driver actually sounds.
+ """
+
+ def test_every_slice_sounds_the_instructions_its_envelopes_describe(
+ self,
+ played: Dict[str, ChannelInstructions],
+ requests: Dict[str, SampleExport],
+ ) -> None:
+ for name, request in requests.items():
+ for channel, instructions in instructions_from_instruments(request.instruments).items():
+ sounded = played[name][channel][: len(instructions)]
+ assert [resting(instruction) for instruction in sounded] == [
+ resting(playable(instruction)) for instruction in instructions
+ ]
+
+ def test_a_channel_the_request_leaves_out_rests_throughout(
+ self,
+ played: Dict[str, ChannelInstructions],
+ requests: Dict[str, SampleExport],
+ ) -> None:
+ for name, request in requests.items():
+ carried = {instrument.channel for instrument in request.instruments}
+ for channel in set(ChannelName.items()) - carried:
+ assert not any(instruction.on for instruction in played[name][channel])
+
+ def test_the_console_sounds_the_reconstructions_own_waveform(
+ self,
+ played: Dict[str, ChannelInstructions],
+ instrument_catalog: Dict[str, Sample],
+ ) -> None:
+ """The envelopes state the span a reconstruction is audible over, so the console sounds the
+ very waveform it was built as there, and what lies past that span is silence on both sides.
+ """
+ for name, sample in instrument_catalog.items():
+ rendered = mix(list(render_channels(played[name], sample.reconstruction.config).values()))
+ approximation = sounded_approximation(sample.reconstruction)
+ audible = min(len(rendered), len(approximation))
+
+ assert np.array_equal(rendered[:audible], approximation[:audible])
+ assert not np.any(rendered[audible:])
+ assert not np.any(approximation[audible:])
+
+
+@pytest.fixture(scope="module")
+def project_song(integration_project: Project) -> Song:
+ """The song the console plays the integration project's arrangement as."""
+ return song_from_project(integration_project, SONG_START)
+
+
+@pytest.fixture(scope="module")
+def project_file(
+ backend: NSFBackend,
+ integration_project: Project,
+ nsf_output_dir: Optional[Path],
+ tmp_path_factory: pytest.TempPathFactory,
+) -> Path:
+ """The whole arrangement written through the backend the application registers.
+
+ It joins the samples in the emitted artifacts, so the arrangement can be listened to
+ beside the instruments it is built from.
+ """
+ destination = resolve_output_path(
+ nsf_output_dir,
+ tmp_path_factory.mktemp("nsf-project"),
+ f"{PROJECT_ARTIFACT}{EXT_FILE_NSF}",
+ )
+ backend.write_project(destination, ProjectExport(project=integration_project))
+ return destination
+
+
+class TestTheBackendWritesAWholeSong:
+ """What reaches disk when the application exports its arrangement to the console."""
+
+ def test_the_project_reaches_a_file_a_player_recognizes(self, project_file: Path) -> None:
+ assert project_file.read_bytes()[: len(NSF_MAGIC)] == NSF_MAGIC
+
+ def test_the_console_sounds_the_arrangement_the_project_states(
+ self,
+ project_file: Path,
+ project_song: Song,
+ integration_project: Project,
+ ) -> None:
+ """This closes the loop a project export opens: the arrangement was played out row by
+ row, compressed to a token stream per plane, decoded by the 6502 and written to the APU, and
+ what stood in those registers is the very song the sequencer sounds.
+ """
+ trace = captured_run(
+ project_file.read_bytes(),
+ play_calls_reaching(project_song, project_song.ticks),
+ )
+ played = instructions_from_trace(trace, get_timer_table(tuning_from_project(integration_project)))
+ for channel, instructions in song_instructions(integration_project).items():
+ sounded = played[channel][: len(instructions)]
+ assert [resting(instruction) for instruction in sounded] == [
+ resting(playable(instruction)) for instruction in instructions
+ ]
+
+ def test_the_song_comes_round_rather_than_falling_silent(
+ self,
+ project_file: Path,
+ project_song: Song,
+ integration_project: Project,
+ ) -> None:
+ """The file repeats, so the calls past the arrangement's end sound its first ticks again."""
+ ticks = project_song.ticks
+ trace = captured_run(project_file.read_bytes(), play_calls_reaching(project_song, 2 * ticks))
+ played = instructions_from_trace(trace, get_timer_table(tuning_from_project(integration_project)))
+ for channel, sounded in played.items():
+ assert len(sounded) > ticks
+ assert [resting(instruction) for instruction in sounded[ticks : 2 * ticks]] == [
+ resting(instruction) for instruction in sounded[:ticks]
+ ]
diff --git a/tests/integration/nsf/test_compression_report.py b/tests/integration/nsf/test_compression_report.py
new file mode 100644
index 000000000..a7863419d
--- /dev/null
+++ b/tests/integration/nsf/test_compression_report.py
@@ -0,0 +1,320 @@
+from dataclasses import dataclass
+from math import ceil
+from pathlib import Path
+from time import process_time
+from typing import Dict, Final, List, Optional, Sequence, Tuple
+
+import pytest
+
+from sampletones_core.project.project import Project
+from sampletones_core.project.voices.sample import Sample
+from sampletones_player.compression.compressed import CompressedPlanes
+from sampletones_player.compression.decode import decode_planes
+from sampletones_player.compression.dictionary.table import phrase_table
+from sampletones_player.compression.encode import STREAM_START, encode_planes
+from sampletones_player.compression.matches.cache import MatchCache
+from sampletones_player.compression.matches.index import PlaneIndex
+from sampletones_player.compression.matches.matcher import PhraseMatcher
+from sampletones_player.compression.options import CodecOptions
+from sampletones_player.compression.parse.plane import parse_plane
+from sampletones_player.compression.planes.rebuild import streams_from_planes
+from sampletones_player.compression.planes.song import SongPlanes
+from sampletones_player.driver.image import DriverImage
+from sampletones_player.registers.streams import ChannelStreams
+from sampletones_player.specification.compression import (
+ MAX_LITERAL_BYTES,
+ PLANE_COUNT,
+ PLANE_STATE_SIZE,
+)
+from sampletones_player.specification.registers import DUTY_CYCLE_SHIFT
+from sampletones_player.specification.song import SONG_HEADER_SIZE
+from tests.integration.nsf.corpus import LONG_ARRANGEMENT, CorpusEntry, build_corpus
+from tests.integration.nsf.report import ReportRow, write_csv, write_markdown
+from tests.integration.nsf.songs import available_bytes
+from tests.integration.output import resolve_output_directory, resolve_output_path
+from tests.integration.paths import COMPRESSION_OUTPUT_ENV
+
+LITERALS: Final[str] = "literals"
+HOLDS: Final[str] = "holds"
+SEARCH: Final[str] = "search"
+RECORDS: Final[str] = "records"
+REGISTER_PLANES: Final[str] = "register planes"
+SPLIT_CONTROL: Final[str] = "split control"
+CONTROL_LEVEL_MASK: Final[int] = 0x3F
+
+PLANE_VARIANTS: Final[Tuple[Tuple[str, CodecOptions], ...]] = (
+ (LITERALS, CodecOptions(holds=False, phrases=False, transposition=False, search=False)),
+ (HOLDS, CodecOptions(holds=True, phrases=False, transposition=False, search=False)),
+ ("instruments", CodecOptions(holds=True, phrases=True, transposition=False, search=False)),
+ ("transposition", CodecOptions(holds=True, phrases=True, transposition=True, search=False)),
+ (SEARCH, CodecOptions(holds=True, phrases=True, transposition=True, search=True)),
+)
+
+CSV_FILENAME: Final[str] = "report.csv"
+MARKDOWN_FILENAME: Final[str] = "report.md"
+
+
+@dataclass(frozen=True)
+class Encoding:
+ """One corpus song compressed under one variant of the codec."""
+
+ entry: CorpusEntry
+ variant: str
+ planes: SongPlanes
+ compressed: CompressedPlanes
+ seconds: float
+
+ @property
+ def size(self) -> int:
+ """The bytes the dictionary, the streams and the pitch table take together."""
+ return self.compressed.size + len(self.entry.pitches.data)
+
+ @property
+ def streams(self) -> int:
+ """The bytes the token streams take, which is the part that grows with the song."""
+ return sum(len(stream) for stream in self.compressed.streams)
+
+
+def _register_planes(streams: ChannelStreams) -> Tuple[bytes, ...]:
+ return tuple(
+ bytes(tick.values[register] for tick in stream)
+ for stream in streams.padded
+ for register in range(len(stream[0].values))
+ )
+
+
+def _split_control_planes(planes: SongPlanes) -> Tuple[bytes, ...]:
+ split: List[bytes] = []
+ for channels in planes.ordered:
+ if channels in (planes.pulse1, planes.pulse2):
+ split.append(bytes(control >> DUTY_CYCLE_SHIFT for control in channels.control))
+ split.append(bytes(control & CONTROL_LEVEL_MASK for control in channels.control))
+ else:
+ split.append(channels.control)
+
+ split.append(channels.value)
+
+ return tuple(split)
+
+
+def _coded_size(planes: Sequence[bytes], options: CodecOptions) -> int:
+ cache = MatchCache(PlaneIndex.from_plane(plane) for plane in planes)
+ table = phrase_table(())
+ entries = frozenset({STREAM_START})
+ return sum(
+ parse_plane(
+ PhraseMatcher(table, plane, cache),
+ options,
+ entries,
+ ).size
+ for plane in range(len(planes))
+ )
+
+
+@pytest.fixture(scope="module")
+def corpus(
+ instrument_catalog: Dict[str, Sample],
+ integration_project: Project,
+) -> Tuple[CorpusEntry, ...]:
+ """The songs the report measures: each sample alone, and the arrangement at two lengths."""
+ return build_corpus(instrument_catalog, integration_project)
+
+
+@pytest.fixture(scope="module")
+def encodings(corpus: Tuple[CorpusEntry, ...]) -> Tuple[Encoding, ...]:
+ """Every corpus song compressed under every variant of the codec."""
+ encoded: List[Encoding] = []
+ for entry in corpus:
+ planes = entry.planes
+ for variant, options in PLANE_VARIANTS:
+ started = process_time()
+ compressed = encode_planes(
+ planes,
+ entry.seeds,
+ options=options,
+ boundaries=frozenset(),
+ )
+ encoded.append(
+ Encoding(
+ entry=entry,
+ variant=variant,
+ planes=planes,
+ compressed=compressed,
+ seconds=process_time() - started,
+ )
+ )
+
+ return tuple(encoded)
+
+
+def _measured_row(
+ entry: CorpusEntry,
+ variant: str,
+ planes: Sequence[bytes],
+ fixed: int,
+ space: int,
+) -> ReportRow:
+ _, holds = PLANE_VARIANTS[1]
+ started = process_time()
+ coded = _coded_size(planes, holds)
+ return ReportRow(
+ corpus=entry.name,
+ variant=variant,
+ ticks=entry.song.ticks,
+ size=fixed + coded,
+ variable=coded,
+ phrases=0,
+ dictionary=0,
+ seconds=process_time() - started,
+ records=entry.records,
+ space=space,
+ )
+
+
+def _baseline_rows(entry: CorpusEntry, space: int) -> Tuple[ReportRow, ...]:
+ return (
+ ReportRow(
+ corpus=entry.name,
+ variant=RECORDS,
+ ticks=entry.song.ticks,
+ size=entry.records,
+ variable=entry.records,
+ phrases=0,
+ dictionary=0,
+ seconds=0.0,
+ records=entry.records,
+ space=space,
+ ),
+ _measured_row(entry, REGISTER_PLANES, _register_planes(entry.song.streams), 0, space),
+ _measured_row(
+ entry,
+ SPLIT_CONTROL,
+ _split_control_planes(entry.planes),
+ len(entry.pitches.data),
+ space,
+ ),
+ )
+
+
+def _rows(
+ corpus: Sequence[CorpusEntry],
+ encodings: Sequence[Encoding],
+ space: int,
+) -> Tuple[ReportRow, ...]:
+ rows: List[ReportRow] = []
+ for entry in corpus:
+ rows.extend(_baseline_rows(entry, space))
+ for encoding in encodings:
+ if encoding.entry is not entry:
+ continue
+
+ rows.append(
+ ReportRow(
+ corpus=entry.name,
+ variant=encoding.variant,
+ ticks=entry.song.ticks,
+ size=encoding.size,
+ variable=encoding.streams,
+ phrases=len(encoding.compressed.phrases),
+ dictionary=encoding.compressed.phrases.size,
+ seconds=encoding.seconds,
+ records=entry.records,
+ space=space,
+ )
+ )
+
+ return tuple(rows)
+
+
+class TestTheCodecAnswersWithTheSongItWasGiven:
+ """What the encoder writes, the decoder plays back, whichever layers are switched on."""
+
+ def test_every_encoding_plays_back_as_the_planes_it_was_written_from(
+ self,
+ encodings: Tuple[Encoding, ...],
+ ) -> None:
+ for encoding in encodings:
+ assert decode_planes(encoding.compressed) == encoding.planes
+
+ def test_every_encoding_reaches_the_registers_the_song_writes(
+ self,
+ encodings: Tuple[Encoding, ...],
+ ) -> None:
+ """The planes are a reading of the streams, so playing them back writes the same registers."""
+ for encoding in encodings:
+ played = streams_from_planes(decode_planes(encoding.compressed), encoding.entry.pitches)
+ written = encoding.entry.song.streams
+ assert [played.at(tick) for tick in range(written.ticks)] == [
+ written.at(tick) for tick in range(written.ticks)
+ ]
+
+ def test_a_plane_the_codec_finds_nothing_in_stays_within_its_literal_bound(
+ self,
+ encodings: Tuple[Encoding, ...],
+ ) -> None:
+ """With every layer switched off a plane costs its own bytes and one opcode per run of them."""
+ for encoding in encodings:
+ if encoding.variant != LITERALS:
+ continue
+
+ bound = encoding.compressed.ticks + ceil(encoding.compressed.ticks / MAX_LITERAL_BYTES)
+ for stream in encoding.compressed.streams:
+ assert len(stream) <= bound
+
+
+class TestTheProgramAreaHoldsAWholeSong:
+ """What the compression is for: an arrangement of minutes rather than seconds."""
+
+ def test_a_three_minute_arrangement_fits_the_program_area(
+ self,
+ encodings: Tuple[Encoding, ...],
+ driver_image: DriverImage,
+ ) -> None:
+ searched = [
+ encoding for encoding in encodings if encoding.entry.name == LONG_ARRANGEMENT and encoding.variant == SEARCH
+ ]
+ assert searched
+ assert SONG_HEADER_SIZE + searched[0].size <= available_bytes(driver_image)
+
+ def test_every_layer_undercuts_a_record_per_tick(
+ self,
+ encodings: Tuple[Encoding, ...],
+ ) -> None:
+ """The pitch table is paid once, so what a song's own data is held against is the records.
+
+ A tone channel writes three planes and three registers, and the noise channel two of
+ each, so spelling every plane out reaches a record per tick and the opcodes counting the
+ runs. The layers are what buy a song its room, and each of them undercuts the record by
+ several times over.
+ """
+ for encoding in encodings:
+ if encoding.variant == LITERALS:
+ continue
+
+ assert encoding.compressed.size < encoding.entry.records
+
+
+class TestTheReportStatesWhatEachLayerSaves:
+ """The measurements the format's constants are settled from."""
+
+ def test_the_report_is_written(
+ self,
+ corpus: Tuple[CorpusEntry, ...],
+ encodings: Tuple[Encoding, ...],
+ driver_image: DriverImage,
+ compression_output_dir: Optional[Path],
+ tmp_path: Path,
+ ) -> None:
+ rows = _rows(corpus, encodings, available_bytes(driver_image))
+ csv_path = resolve_output_path(compression_output_dir, tmp_path, CSV_FILENAME)
+ markdown_path = resolve_output_path(compression_output_dir, tmp_path, MARKDOWN_FILENAME)
+ write_csv(rows, csv_path)
+ write_markdown(rows, markdown_path, PLANE_COUNT * PLANE_STATE_SIZE)
+ assert csv_path.read_text(encoding="utf-8").count("\n") == len(rows) + 1
+ assert markdown_path.exists()
+
+
+@pytest.fixture(scope="session")
+def compression_output_dir() -> Optional[Path]:
+ """The persistent output directory ``SAMPLETONES_COMPRESSION_OUTPUT_DIR`` names."""
+ return resolve_output_directory(COMPRESSION_OUTPUT_ENV)
diff --git a/tests/integration/nsf/test_driver_audio.py b/tests/integration/nsf/test_driver_audio.py
new file mode 100644
index 000000000..02018b35c
--- /dev/null
+++ b/tests/integration/nsf/test_driver_audio.py
@@ -0,0 +1,126 @@
+from typing import Dict, List
+
+import numpy as np
+import pytest
+
+from sampletones_core.audio.mixing import mix
+from sampletones_core.constants.enums import ChannelName
+from sampletones_core.generators.render import render_channels
+from sampletones_core.instructions import InstructionUnion
+from sampletones_core.project.voices.sample import Sample
+from sampletones_core.timers.utils import get_timer_table
+from sampletones_player.builder import song_from_reconstruction
+from sampletones_player.registers.playable import playable
+from tests.integration.nsf.console.instructions import instructions_from_trace, sounded_approximation
+from tests.integration.nsf.console.session import captured_trace
+from tests.integration.nsf.exports import exported_information
+
+ChannelInstructions = Dict[ChannelName, List[InstructionUnion]]
+
+
+def played_by_console(sample: Sample) -> ChannelInstructions:
+ """The per-tick instructions the console sounds, read back out of the registers it wrote."""
+ song = song_from_reconstruction(sample.reconstruction, loop_tick=None)
+ trace = captured_trace(song, exported_information(sample.name))
+ return instructions_from_trace(trace, get_timer_table(sample.reconstruction.config.tuning))
+
+
+def resting(instruction: InstructionUnion) -> InstructionUnion:
+ """A sounding instruction as it stands, and a rest as the canonical silent one.
+
+ A stream holds a channel's pitch and timbre through a rest so the driver leaves the timer's
+ high byte alone, so what a rest carries beyond its silence is the channel's own history.
+ """
+ if instruction.on:
+ return instruction
+
+ silent: InstructionUnion = type(instruction).null_instruction()
+ return silent
+
+
+@pytest.fixture(scope="module")
+def played(instrument_catalog: Dict[str, Sample]) -> Dict[str, ChannelInstructions]:
+ """What the console sounds for every sample in the catalog, together covering all four channels."""
+ return {name: played_by_console(sample) for name, sample in instrument_catalog.items()}
+
+
+@pytest.fixture(scope="module")
+def rendered(
+ played: Dict[str, ChannelInstructions],
+ instrument_catalog: Dict[str, Sample],
+) -> Dict[str, np.ndarray]:
+ """The waveform the console's instructions sound as, rendered on the reconstruction's own engine."""
+ return {
+ name: mix(list(render_channels(played[name], sample.reconstruction.config).values()))
+ for name, sample in instrument_catalog.items()
+ }
+
+
+class TestTheConsoleSoundsTheReconstruction:
+ """What the driver puts on the APU, decoded back into the terms the reconstruction speaks.
+
+ A frame reaches the console through :func:`playable`, which states what the planes can carry
+ of it, so the console is held against the frames it can sound rather than against ones naming
+ a divider offset it has nowhere to put.
+ """
+
+ def test_every_played_channel_sounds_its_own_instructions(
+ self,
+ played: Dict[str, ChannelInstructions],
+ instrument_catalog: Dict[str, Sample],
+ ) -> None:
+ for name, sample in instrument_catalog.items():
+ for channel, instructions in sample.reconstruction.instructions.items():
+ sounded = played[name][channel][: len(instructions)]
+ assert [resting(instruction) for instruction in sounded] == [
+ resting(playable(instruction)) for instruction in instructions
+ ]
+
+ def test_a_channel_the_reconstruction_leaves_out_rests_throughout(
+ self,
+ played: Dict[str, ChannelInstructions],
+ instrument_catalog: Dict[str, Sample],
+ ) -> None:
+ for name, sample in instrument_catalog.items():
+ silent = set(ChannelName.items()) - set(sample.reconstruction.instructions)
+ for channel in silent:
+ assert not any(instruction.on for instruction in played[name][channel])
+
+ def test_the_catalog_sounds_all_four_channels(self, played: Dict[str, ChannelInstructions]) -> None:
+ sounded = {
+ channel
+ for instructions in played.values()
+ for channel, stream in instructions.items()
+ if any(instruction.on for instruction in stream)
+ }
+ assert sounded == set(ChannelName.items())
+
+ def test_every_run_ends_with_every_channel_silent(self, played: Dict[str, ChannelInstructions]) -> None:
+ for instructions in played.values():
+ assert not any(stream[-1].on for stream in instructions.values())
+
+
+class TestTheConsoleRendersTheReconstructionsAudio:
+ """The captured trace, sounded through the very generators the reconstruction was built on.
+
+ The reconstruction is rendered from the frames the console can sound, so the two sides are
+ held against one waveform — see :func:`sounded_approximation`.
+ """
+
+ def test_the_console_reproduces_the_reconstructions_waveform(
+ self,
+ rendered: Dict[str, np.ndarray],
+ instrument_catalog: Dict[str, Sample],
+ ) -> None:
+ for name, sample in instrument_catalog.items():
+ approximation = sounded_approximation(sample.reconstruction)
+ assert np.array_equal(rendered[name][: len(approximation)], approximation)
+
+ def test_the_audio_past_the_reconstruction_is_silent(
+ self,
+ rendered: Dict[str, np.ndarray],
+ instrument_catalog: Dict[str, Sample],
+ ) -> None:
+ for name, sample in instrument_catalog.items():
+ approximation = sounded_approximation(sample.reconstruction)
+ assert not np.any(rendered[name][len(approximation) :])
diff --git a/tests/integration/nsf/test_driver_bend.py b/tests/integration/nsf/test_driver_bend.py
new file mode 100644
index 000000000..1db9a4803
--- /dev/null
+++ b/tests/integration/nsf/test_driver_bend.py
@@ -0,0 +1,133 @@
+from dataclasses import dataclass
+from typing import Final, List, Self, Tuple
+
+import pytest
+
+from sampletones_core.constants.enums import ChannelName
+from sampletones_player.song import Song
+from sampletones_player.specification.binary import BYTE_VALUES
+from sampletones_player.specification.registers import PULSE1_TIMER_HIGH, TIMER_HIGH_SHIFT
+from sampletones_player.trace.trace import RegisterTrace
+from tests.integration.nsf.console.instructions import channel_values, timer_value
+from tests.integration.nsf.console.machine import register_file
+from tests.integration.nsf.console.session import captured_trace, play_calls_covering
+from tests.integration.nsf.exports import exported_information
+from tests.suite.base import BaseTestSuite
+from tests.suite.case import BaseAutolabelTestCase
+from tests.suite.player import PLAYER_PITCHES, bent_song
+
+NTSC_RATE: Final[int] = 60
+SONG_NAME: Final[str] = "bend"
+
+MIDRANGE_INDEX: Final[int] = 33
+BOUNDARY_INDEX: Final[int] = 17
+WIDEST_RESIDUAL: Final[int] = 57
+TIMER_LOW_INDEX: Final[int] = 1
+TIMER_HIGH_INDEX: Final[int] = 2
+
+
+def sounded_dividers(song: Song) -> List[int]:
+ """The divider the console leaves on the first pulse channel after each tick it sounds.
+
+ Args:
+ song: The song to export and run.
+
+ Returns:
+ List[int]: One divider per tick the song covers, in order.
+ """
+ trace = captured_trace(song, exported_information(SONG_NAME))
+ dividers = []
+ for registers in register_file(trace):
+ values = channel_values(registers, ChannelName.PULSE1)
+ dividers.append(timer_value(values[TIMER_LOW_INDEX], values[TIMER_HIGH_INDEX]))
+
+ return dividers[: song.planes.ticks]
+
+
+class TestTheDriverSoundsTheDividerABendPlaneNames(BaseTestSuite):
+ """The assembled 6502 driver run on py65, its timer registers read back tick by tick.
+
+ A plane byte reaches the timer through a sign extension and a sixteen-bit add, which is the
+ one piece of arithmetic the driver performs on a song's behalf. These runs state a bend
+ outright — the encoders leave the dimension to the note for now — and hold the console to the
+ divider each tick is meant to sound at.
+ """
+
+ @dataclass(frozen=True, kw_only=True)
+ class TestCase(BaseAutolabelTestCase):
+ """One run of a bent note, and the divider each of its ticks reaches.
+
+ Attributes:
+ pitch_index: The pitch the value plane names throughout.
+ bends: The divider steps each tick stands away from that pitch.
+ expected: The divider each tick sounds at.
+ """
+
+ pitch_index: int
+ bends: Tuple[int, ...]
+ expected: Tuple[int, ...]
+
+ @classmethod
+ def bending(cls, pitch_index: int, bends: Tuple[int, ...]) -> Self:
+ """A case whose expectation is the note's own divider moved by each bend."""
+ return cls(
+ pitch_index=pitch_index,
+ bends=bends,
+ expected=tuple(PLAYER_PITCHES.timers[pitch_index] + bend for bend in bends),
+ )
+
+ @property
+ def label(self) -> str:
+ return f"pitch {self.pitch_index} bent {' '.join(f'{bend:+d}' for bend in self.bends)}"
+
+ test_cases = (
+ TestCase.bending(MIDRANGE_INDEX, (0, 0, 0, 0)),
+ TestCase.bending(MIDRANGE_INDEX, (0, 7, 3, 0)),
+ TestCase.bending(MIDRANGE_INDEX, (0, -7, -3, 0)),
+ TestCase.bending(MIDRANGE_INDEX, (WIDEST_RESIDUAL, -WIDEST_RESIDUAL, 0, 1)),
+ TestCase.bending(BOUNDARY_INDEX, (0, -1, 3, -2)),
+ )
+
+ @pytest.mark.parametrize("test_case", test_cases, ids=lambda case: case.label)
+ def test_the_console_sounds_the_divider_the_bend_states(self, test_case: TestCase) -> None:
+ song = bent_song(test_case.pitch_index, test_case.bends, NTSC_RATE)
+ assert sounded_dividers(song) == list(test_case.expected)
+
+ @pytest.mark.parametrize("test_case", test_cases, ids=lambda case: case.label)
+ def test_the_model_states_the_writes_the_driver_makes(self, test_case: TestCase) -> None:
+ song = bent_song(test_case.pitch_index, test_case.bends, NTSC_RATE)
+ trace = captured_trace(song, exported_information(SONG_NAME))
+ assert trace == RegisterTrace.from_song(song, play_calls_covering(song))
+
+
+class TestABendCrossingTheTimersHighHalf:
+ """A divider whose high half moves is where the driver's carry and its shadow both show.
+
+ The high half reaches the register only where it differs from the last one written, so a bend
+ that carries into it is the one that proves the carry survives the two halves of the add and
+ that the shadow follows what was actually written.
+ """
+
+ BENDS: Final[Tuple[int, ...]] = (0, -1, 3, -2)
+
+ @pytest.fixture
+ def crossing(self) -> Song:
+ """A note standing on a high-byte boundary, bent to either side of it."""
+ return bent_song(BOUNDARY_INDEX, self.BENDS, NTSC_RATE)
+
+ def test_the_note_stands_on_a_boundary(self) -> None:
+ assert PLAYER_PITCHES.timers[BOUNDARY_INDEX] % BYTE_VALUES == 0
+
+ def test_the_carry_reaches_the_timers_high_half(self, crossing: Song) -> None:
+ divider = PLAYER_PITCHES.timers[BOUNDARY_INDEX]
+ assert sounded_dividers(crossing) == [divider + bend for bend in self.BENDS]
+
+ def test_the_high_half_is_written_wherever_the_bend_moves_it(self, crossing: Song) -> None:
+ divider = PLAYER_PITCHES.timers[BOUNDARY_INDEX]
+ halves = [(divider + bend) >> TIMER_HIGH_SHIFT for bend in self.BENDS]
+ crossings = sum(1 for earlier, later in zip(halves, halves[1:]) if earlier != later)
+
+ trace = captured_trace(crossing, exported_information(SONG_NAME))
+ written = [write for writes in trace.play_calls for write in writes if write.address == PULSE1_TIMER_HIGH]
+ assert crossings
+ assert len(written) == crossings
diff --git a/tests/integration/nsf/test_driver_trace.py b/tests/integration/nsf/test_driver_trace.py
new file mode 100644
index 000000000..8640103d5
--- /dev/null
+++ b/tests/integration/nsf/test_driver_trace.py
@@ -0,0 +1,185 @@
+from dataclasses import dataclass
+from typing import Final
+
+import pytest
+
+from sampletones_core.project.voices.sample import Sample
+from sampletones_player.builder import song_from_reconstruction
+from sampletones_player.driver.image import DriverImage
+from sampletones_player.nsf.song import song_to_bytes
+from sampletones_player.song import Song
+from sampletones_player.specification.binary import WORD_SIZE
+from sampletones_player.specification.nsf import PROGRAM_SIZE
+from sampletones_player.specification.song import STEP_FRACTION_OFFSET, STEP_WHOLE_OFFSET
+from sampletones_player.trace.trace import RegisterTrace
+from tests.integration.nsf.console.session import (
+ TRAILING_CALLS,
+ captured_trace,
+ captured_trace_over,
+ play_calls_covering,
+ play_calls_reaching,
+)
+from tests.integration.nsf.exports import exported_information
+from tests.suite.base import BaseTestSuite
+from tests.suite.case import BaseAutolabelTestCase
+
+HALF_RATE: Final[int] = 30
+DOUBLE_RATE: Final[int] = 120
+NTSC_RATE: Final[int] = 60
+FAST_RATE: Final[int] = 300
+ROUNDS: Final[int] = 4
+
+
+@pytest.fixture
+def trace(song: Song, sample: Sample) -> RegisterTrace:
+ """Every APU write the assembled driver makes over a full run of the sample."""
+ return captured_trace(song, exported_information(sample.name))
+
+
+@pytest.fixture
+def expected(song: Song) -> RegisterTrace:
+ """The writes the model states a correct driver makes over that same run."""
+ return RegisterTrace.from_song(song, play_calls_covering(song))
+
+
+class TestTheDriverWritesWhatTheModelStates:
+ """The assembled 6502 driver run on py65, held against `RegisterTrace.from_song`."""
+
+ def test_initialization_readies_the_console_the_way_the_model_states(
+ self,
+ trace: RegisterTrace,
+ expected: RegisterTrace,
+ ) -> None:
+ assert trace.initialization == expected.initialization
+
+ def test_every_play_call_writes_what_the_model_states(
+ self,
+ trace: RegisterTrace,
+ expected: RegisterTrace,
+ ) -> None:
+ assert trace.play_calls == expected.play_calls
+
+ def test_a_song_without_a_loop_stops_where_it_ends(self, trace: RegisterTrace) -> None:
+ assert all(not writes for writes in trace.play_calls[-TRAILING_CALLS:])
+
+ def test_the_run_sounds_every_tick_the_song_covers(
+ self,
+ trace: RegisterTrace,
+ song: Song,
+ ) -> None:
+ sounding = [writes for writes in trace.play_calls if writes]
+ assert len(sounding) + 1 == song.ticks
+
+
+class TestAReClockedStreamPlaysTheSameTicks(BaseTestSuite):
+ """A reconstruction built at another rate reaches the console through the same data.
+
+ The file states one stream and the rate it was built at, and the driver advances that stream
+ by a fractional number of ticks each call. Every rate therefore plays the same ticks in the
+ same order, spread over as many calls as the rate asks for.
+ """
+
+ @dataclass(frozen=True, kw_only=True)
+ class TestCase(BaseAutolabelTestCase):
+ expected: int
+
+ @property
+ def label(self) -> str:
+ return f"{self.expected} Hz"
+
+ test_cases = (
+ TestCase(expected=HALF_RATE),
+ TestCase(expected=DOUBLE_RATE),
+ )
+
+ @pytest.mark.parametrize("test_case", test_cases, ids=lambda case: case.label)
+ def test_the_driver_writes_what_the_model_states(
+ self,
+ test_case: TestCase,
+ sample: Sample,
+ ) -> None:
+ reclocked = sample.reconstruction.with_nes_frequency(test_case.expected)
+ song = song_from_reconstruction(reclocked, loop_tick=None)
+
+ trace = captured_trace(song, exported_information(sample.name))
+ assert trace == RegisterTrace.from_song(song, play_calls_covering(song))
+
+ @pytest.mark.parametrize("test_case", test_cases, ids=lambda case: case.label)
+ def test_the_rate_reaches_the_console_as_the_step_alone(
+ self,
+ test_case: TestCase,
+ song: Song,
+ sample: Sample,
+ driver_image: DriverImage,
+ ) -> None:
+ reclocked = sample.reconstruction.with_nes_frequency(test_case.expected)
+ available = PROGRAM_SIZE - len(driver_image.code)
+
+ block = song_to_bytes(song, available)
+ reclocked_block = song_to_bytes(song_from_reconstruction(reclocked, loop_tick=None), available)
+
+ assert block[STEP_FRACTION_OFFSET + WORD_SIZE :] == reclocked_block[STEP_FRACTION_OFFSET + WORD_SIZE :]
+ assert block[:STEP_WHOLE_OFFSET] == reclocked_block[:STEP_WHOLE_OFFSET]
+
+
+class TestARepeatingSongComesRoundWhereTheModelSaysItDoes(BaseTestSuite):
+ """A song that repeats re-enters its streams partway through.
+
+ What the driver restores at the loop is the byte each plane resumes at, which
+ the header states, so a plane comes back holding nothing of the run that led up to it. The
+ tick the loop returns to therefore starts a token of its own on every plane.
+ """
+
+ @dataclass(frozen=True, kw_only=True)
+ class TestCase(BaseAutolabelTestCase):
+ expected: int
+ name: str
+ nes_frequency: int
+ remaining: int
+
+ @property
+ def label(self) -> str:
+ return self.name
+
+ test_cases = (
+ TestCase(name="half-the-song", nes_frequency=NTSC_RATE, remaining=0, expected=ROUNDS),
+ TestCase(name="one-tick-loop", nes_frequency=NTSC_RATE, remaining=1, expected=ROUNDS),
+ TestCase(name="one-tick-loop-fast", nes_frequency=FAST_RATE, remaining=1, expected=ROUNDS),
+ TestCase(name="half-the-song-fast", nes_frequency=FAST_RATE, remaining=0, expected=ROUNDS),
+ )
+
+ @staticmethod
+ def repeating(sample: Sample, test_case: "TestCase") -> Song:
+ reclocked = sample.reconstruction.with_nes_frequency(test_case.nes_frequency)
+ ticks = song_from_reconstruction(reclocked, loop_tick=None).ticks
+ loop_tick = ticks - test_case.remaining if test_case.remaining else ticks // 2
+ return song_from_reconstruction(reclocked, loop_tick=loop_tick)
+
+ @pytest.mark.parametrize("test_case", test_cases, ids=lambda case: case.label)
+ def test_the_driver_writes_what_the_model_states(
+ self,
+ test_case: TestCase,
+ sample: Sample,
+ ) -> None:
+ song = self.repeating(sample, test_case)
+ assert song.loop_tick is not None
+ covered = song.ticks + test_case.expected * (song.ticks - song.loop_tick)
+ calls = play_calls_reaching(song, covered)
+
+ trace = captured_trace_over(song, exported_information(sample.name), calls)
+ assert trace == RegisterTrace.from_song(song, calls)
+
+ @pytest.mark.parametrize("test_case", test_cases, ids=lambda case: case.label)
+ def test_the_run_keeps_sounding_past_the_songs_end(
+ self,
+ test_case: TestCase,
+ sample: Sample,
+ ) -> None:
+ """A song without a loop falls silent where it ends, and one with a loop plays on."""
+ song = self.repeating(sample, test_case)
+ assert song.loop_tick is not None
+ covered = song.ticks + test_case.expected * (song.ticks - song.loop_tick)
+ calls = play_calls_reaching(song, covered)
+
+ trace = captured_trace_over(song, exported_information(sample.name), calls)
+ assert any(writes for writes in trace.play_calls[-TRAILING_CALLS:])
diff --git a/tests/integration/nsf/test_nsf_pipeline.py b/tests/integration/nsf/test_nsf_pipeline.py
new file mode 100644
index 000000000..fda31e4cf
--- /dev/null
+++ b/tests/integration/nsf/test_nsf_pipeline.py
@@ -0,0 +1,157 @@
+import struct
+from pathlib import Path
+from typing import Dict, Tuple
+
+import pytest
+
+from sampletones_core.constants.enums import ChannelName
+from sampletones_core.project.voices.sample import Sample
+from sampletones_core.reconstructions import Reconstruction
+from sampletones_player.builder import song_from_reconstruction
+from sampletones_player.driver.image import DriverImage
+from sampletones_player.nsf.file import write_nsf
+from sampletones_player.nsf.song import song_to_bytes
+from sampletones_player.song import Song
+from sampletones_player.specification.binary import WORD_SIZE
+from sampletones_player.specification.compression import PLANE_COUNT
+from sampletones_player.specification.nsf import (
+ HEADER_SIZE,
+ NSF_MAGIC,
+ PROGRAM_SIZE,
+)
+from sampletones_player.specification.song import (
+ LOOP_TICK_OFFSET,
+ NO_LOOP,
+ SONG_HEADER_SIZE,
+ STEP_FRACTION_OFFSET,
+ STEP_WHOLE_OFFSET,
+ STREAM_OFFSETS_OFFSET,
+ TOTAL_TICKS_OFFSET,
+)
+from sampletones_shared.paths.extensions import EXT_FILE_RECONSTRUCTION
+from tests.integration.nsf.exports import exported_information
+
+
+def song_block(data: bytes, image: DriverImage) -> bytes:
+ return data[HEADER_SIZE + len(image.code) :]
+
+
+def read_word(data: bytes, offset: int) -> int:
+ return int(struct.unpack_from(" Tuple[int, ...]:
+ return tuple(read_word(block, STREAM_OFFSETS_OFFSET + WORD_SIZE * plane) for plane in range(PLANE_COUNT))
+
+
+@pytest.fixture
+def exported(
+ song: Song,
+ sample: Sample,
+ nsf_paths: Dict[str, Path],
+ driver_image: DriverImage,
+) -> bytes:
+ destination = nsf_paths[sample.name]
+ write_nsf(destination, song, exported_information(sample.name), driver_image)
+ return destination.read_bytes()
+
+
+class TestNsfPipeline:
+ """End-to-end: synthesized and reconstructed samples -> `Song` -> a playable `.nsf`."""
+
+ def test_every_sample_reaches_a_playable_file(
+ self,
+ instrument_catalog: Dict[str, Sample],
+ nsf_paths: Dict[str, Path],
+ driver_image: DriverImage,
+ ) -> None:
+ for name, sample in instrument_catalog.items():
+ song = song_from_reconstruction(sample.reconstruction, loop_tick=None)
+ write_nsf(nsf_paths[name], song, exported_information(name), driver_image)
+ assert nsf_paths[name].read_bytes()[: len(NSF_MAGIC)] == NSF_MAGIC
+
+ def test_the_file_carries_the_shipped_driver(self, exported: bytes, driver_image: DriverImage) -> None:
+ assert exported[HEADER_SIZE : HEADER_SIZE + len(driver_image.code)] == driver_image.code
+
+ def test_the_loaded_image_fits_the_program_area(self, exported: bytes) -> None:
+ assert len(exported) - HEADER_SIZE <= PROGRAM_SIZE
+
+ def test_the_song_follows_the_driver_whole(
+ self,
+ exported: bytes,
+ song: Song,
+ driver_image: DriverImage,
+ ) -> None:
+ available = PROGRAM_SIZE - len(driver_image.code)
+ assert song_block(exported, driver_image) == song_to_bytes(song, available)
+
+
+class TestTheSongTheFileCarries:
+ """What the driver reads out of the block behind it."""
+
+ def test_the_song_states_the_ticks_the_reconstruction_covers(
+ self,
+ exported: bytes,
+ song: Song,
+ driver_image: DriverImage,
+ ) -> None:
+ assert read_word(song_block(exported, driver_image), TOTAL_TICKS_OFFSET) == song.ticks
+
+ def test_the_song_states_the_rate_it_was_built_at(
+ self,
+ exported: bytes,
+ song: Song,
+ driver_image: DriverImage,
+ ) -> None:
+ block = song_block(exported, driver_image)
+ step = song.schedule.fixed_point_step
+ assert (block[STEP_WHOLE_OFFSET], read_word(block, STEP_FRACTION_OFFSET)) == (step.whole, step.fraction)
+
+ def test_a_sample_that_ends_stops_there(
+ self,
+ exported: bytes,
+ driver_image: DriverImage,
+ ) -> None:
+ block = song_block(exported, driver_image)
+ assert read_word(block, LOOP_TICK_OFFSET) == NO_LOOP
+
+ def test_every_stream_begins_inside_the_block(
+ self,
+ exported: bytes,
+ driver_image: DriverImage,
+ ) -> None:
+ block = song_block(exported, driver_image)
+ offsets = stream_offsets(block)
+ assert offsets[0] > SONG_HEADER_SIZE
+ assert all(offset < len(block) for offset in offsets)
+
+ def test_the_streams_stand_in_plane_order(
+ self,
+ exported: bytes,
+ driver_image: DriverImage,
+ ) -> None:
+ offsets = stream_offsets(song_block(exported, driver_image))
+ assert list(offsets) == sorted(offsets)
+
+
+class TestAStoredReconstructionExportsTheSameFile:
+ """A reconstruction saved and read back plays as the very file it played as before."""
+
+ def test_the_round_trip_leaves_the_exported_bytes_alone(
+ self,
+ sample: Sample,
+ song: Song,
+ tmp_path: Path,
+ driver_image: DriverImage,
+ ) -> None:
+ stored = tmp_path / f"{sample.name}{EXT_FILE_RECONSTRUCTION}"
+ sample.reconstruction.save(stored)
+ reloaded = song_from_reconstruction(Reconstruction.load(stored), loop_tick=None)
+
+ information = exported_information(sample.name)
+ before = tmp_path / "before.nsf"
+ after = tmp_path / "after.nsf"
+ write_nsf(before, song, information, driver_image)
+ write_nsf(after, reloaded, information, driver_image)
+
+ assert after.read_bytes() == before.read_bytes()
diff --git a/tests/integration/nsf/test_song_export.py b/tests/integration/nsf/test_song_export.py
new file mode 100644
index 000000000..7f566958c
--- /dev/null
+++ b/tests/integration/nsf/test_song_export.py
@@ -0,0 +1,73 @@
+import struct
+
+import pytest
+
+from sampletones_core.project.project import Project
+from sampletones_core.timing import SongTiming
+from sampletones_player.builder import song_from_project
+from sampletones_player.driver.image import DriverImage
+from sampletones_player.nsf.song import song_to_bytes
+from sampletones_player.song import Song
+from sampletones_player.specification.song import (
+ SONG_HEADER_SIZE,
+ TOTAL_TICKS_OFFSET,
+)
+from sampletones_shared.exceptions import SongTooLargeError
+from tests.integration.nsf.songs import RECORD_BYTES_PER_TICK, available_bytes
+
+
+def read_word(data: bytes, offset: int) -> int:
+ return int(struct.unpack_from(" Song:
+ """The song the console plays the integration project's arrangement as."""
+ return song_from_project(integration_project, loop_tick=None)
+
+
+class TestTheProjectReachesTheConsole:
+ """A whole arrangement flattened into the streams the driver already plays."""
+
+ def test_the_song_lasts_the_ticks_the_projects_groove_gives_its_order(
+ self,
+ integration_project: Project,
+ project_song: Song,
+ ) -> None:
+ groove = SongTiming.from_project(integration_project).groove()
+ assert project_song.ticks == integration_project.song.order_length() * groove.total_ticks
+
+ def test_every_channel_the_order_plays_sounds(self, project_song: Song) -> None:
+ """The fixture's arrangement fills all four channels, so none of them rests throughout."""
+ for stream in (
+ project_song.streams.pulse1,
+ project_song.streams.pulse2,
+ project_song.streams.triangle,
+ project_song.streams.noise,
+ ):
+ assert len(set(stream)) > 1
+
+ def test_the_arrangement_writes_through_the_song_block(
+ self,
+ project_song: Song,
+ driver_image: DriverImage,
+ ) -> None:
+ """The block states the whole arrangement, in less room than a record a tick would take."""
+ block = song_to_bytes(project_song, available_bytes(driver_image))
+ assert read_word(block, TOTAL_TICKS_OFFSET) == project_song.ticks
+ assert len(block) < SONG_HEADER_SIZE + RECORD_BYTES_PER_TICK * project_song.ticks
+
+
+class TestTheProgramAreaBoundsTheSong:
+ """A block outgrowing the room behind the driver is named rather than written short."""
+
+ def test_a_song_the_space_cannot_hold_is_refused(
+ self,
+ project_song: Song,
+ driver_image: DriverImage,
+ ) -> None:
+ """The exporter names the overflow rather than writing a file the console truncates."""
+ block = song_to_bytes(project_song, available_bytes(driver_image))
+
+ with pytest.raises(SongTooLargeError):
+ song_to_bytes(project_song, len(block) - 1)
diff --git a/tests/integration/paths.py b/tests/integration/paths.py
index dc63ef711..c27997891 100644
--- a/tests/integration/paths.py
+++ b/tests/integration/paths.py
@@ -25,3 +25,6 @@ def _repo_root() -> Path:
DOCUMENT_FILENAME: Final[str] = "drums.btp"
GROOVE_DOCUMENT_FILENAME: Final[str] = "drums-groove.btp"
BTP_OUTPUT_ENV: Final[str] = "SAMPLETONES_BTP_OUTPUT_DIR"
+
+NSF_OUTPUT_ENV: Final[str] = "SAMPLETONES_NSF_OUTPUT_DIR"
+COMPRESSION_OUTPUT_ENV: Final[str] = "SAMPLETONES_COMPRESSION_OUTPUT_DIR"
diff --git a/tests/integration/reconstruction/__init__.py b/tests/integration/reconstruction/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/tests/integration/reconstruction/test_conversion_jobs.py b/tests/integration/reconstruction/test_conversion_jobs.py
new file mode 100644
index 000000000..c004b35e7
--- /dev/null
+++ b/tests/integration/reconstruction/test_conversion_jobs.py
@@ -0,0 +1,147 @@
+from pathlib import Path
+
+import pytest
+
+from sampletones_core.configs import Config
+from sampletones_core.constants.enums import ChannelName
+from sampletones_core.reconstructions import Reconstruction, Reconstructor
+from sampletones_core.reconstructions.converter import (
+ DirectoryConversion,
+ GroupConversion,
+ reconstruct_job,
+)
+from sampletones_core.reconstructions.progress import (
+ ReconstructionProgress,
+)
+from sampletones_core.reconstructions.reconstructor.stems.configs.config import StemsConfig
+from sampletones_core.reconstructions.stage import ReconstructionStage
+from sampletones_shared.exceptions import OperationCanceled
+from sampletones_shared.utils.progress import silent_reporter
+from tests.integration.assets.reconstruction import (
+ build_mini_library,
+ three_stem_config,
+ three_stem_reconstruction_config,
+ write_three_stem_recordings,
+)
+from tests.suite.progress import FIRST_REPORT, RecordingReporter, reported_stages
+
+
+def _writing_to(config: Config, directory: Path) -> Config:
+ general = config.general.model_copy(update={"reconstructions_directory": str(directory)})
+ return config.model_copy(update={"general": general})
+
+
+class TestGroupConversionEndToEnd:
+ """Several recordings reach one written reconstruction through the job seam."""
+
+ def test_three_stems_convert_into_one_reconstruction_file(self, tmp_path: Path) -> None:
+ config = _writing_to(three_stem_reconstruction_config(), tmp_path / "out")
+ reconstructor = Reconstructor(config, library=build_mini_library(config))
+ sources = write_three_stem_recordings(config, tmp_path)
+
+ jobs = GroupConversion(sources=sources, stems=three_stem_config()).jobs(config)
+
+ assert len(jobs) == 1
+ written = reconstruct_job((reconstructor, jobs[0], silent_reporter))
+
+ assert written.exists()
+ loaded = Reconstruction.load(written)
+ assert loaded.audio_filepath == sources
+ assert loaded.stems_data.config == three_stem_config()
+ assert set(loaded.stems_data.assignments_by_channel) == set(loaded.playing_channels)
+
+ def test_one_source_converts_the_classic_way(self, tmp_path: Path) -> None:
+ config = _writing_to(Config(), tmp_path / "out")
+ reconstructor = Reconstructor(config, library=build_mini_library(config))
+ source = write_three_stem_recordings(config, tmp_path)[0]
+ stems = StemsConfig.single_entry(list(config.generation.channels))
+
+ jobs = GroupConversion(sources=(source,), stems=stems).jobs(config)
+ written = reconstruct_job((reconstructor, jobs[0], silent_reporter))
+
+ loaded = Reconstruction.load(written)
+ assert written.stem == source.stem
+ assert loaded.audio_filepath == (source,)
+ assert loaded.stems_data.config == stems
+
+
+class TestDirectoryConversionEndToEnd:
+ """A directory converts into one reconstruction per audio file, each from that file alone."""
+
+ def test_each_recording_is_written_on_its_own(self, tmp_path: Path) -> None:
+ config = _writing_to(Config(), tmp_path / "out")
+ reconstructor = Reconstructor(config, library=build_mini_library(config))
+ recordings = tmp_path / "recordings"
+ recordings.mkdir()
+ sources = write_three_stem_recordings(config, recordings)
+ stems = StemsConfig.single_entry([ChannelName.PULSE1], channel_cap=1)
+
+ jobs = DirectoryConversion(directory=recordings, stems=stems).jobs(config)
+
+ assert len(jobs) == len(sources)
+ for job in jobs:
+ written = reconstruct_job((reconstructor, job, silent_reporter))
+ loaded = Reconstruction.load(written)
+ assert loaded.audio_filepath == job.sources
+ assert tuple(loaded.playing_channels) == (ChannelName.PULSE1,)
+
+
+class TestAJobReportsItselfAsItRuns:
+ """A job says which stage it is in and how far that stage has come, over the real pipeline.
+
+ A conversion is one job whatever the number of stems, so without this a whole reconstruction
+ stands at nothing until the file is written. The run reported here is the real one over a
+ small library, so what the stages count is what the reconstruction actually did.
+ """
+
+ def test_the_run_passes_through_its_stages_in_order(self, tmp_path: Path) -> None:
+ config = _writing_to(three_stem_reconstruction_config(), tmp_path / "out")
+ reconstructor = Reconstructor(config, library=build_mini_library(config))
+ sources = write_three_stem_recordings(config, tmp_path)
+ jobs = GroupConversion(sources=sources, stems=three_stem_config()).jobs(config)
+ reporter: RecordingReporter[ReconstructionProgress] = RecordingReporter()
+
+ reconstruct_job((reconstructor, jobs[0], reporter))
+
+ assert reported_stages(reporter.reports) == list(ReconstructionStage)
+
+ def test_the_reading_climbs_from_nothing_to_the_whole_run(self, tmp_path: Path) -> None:
+ config = _writing_to(Config(), tmp_path / "out")
+ reconstructor = Reconstructor(config, library=build_mini_library(config))
+ source = write_three_stem_recordings(config, tmp_path)[0]
+ stems = StemsConfig.single_entry(list(config.generation.channels))
+ jobs = GroupConversion(sources=(source,), stems=stems).jobs(config)
+ reporter: RecordingReporter[ReconstructionProgress] = RecordingReporter()
+
+ reconstruct_job((reconstructor, jobs[0], reporter))
+ readings = [report.fraction for report in reporter.reports]
+
+ assert readings == sorted(readings)
+ assert readings[0] == 0.0
+ assert readings[-1] == 1.0
+
+ def test_the_matching_stage_counts_the_frames_the_recording_holds(self, tmp_path: Path) -> None:
+ config = _writing_to(Config(), tmp_path / "out")
+ reconstructor = Reconstructor(config, library=build_mini_library(config))
+ source = write_three_stem_recordings(config, tmp_path)[0]
+ stems = StemsConfig.single_entry(list(config.generation.channels))
+ jobs = GroupConversion(sources=(source,), stems=stems).jobs(config)
+ reporter: RecordingReporter[ReconstructionProgress] = RecordingReporter()
+
+ reconstruct_job((reconstructor, jobs[0], reporter))
+ matching = [report for report in reporter.reports if report.stage == ReconstructionStage.MATCHING]
+
+ assert [report.completed for report in matching] == list(range(matching[0].total + 1))
+
+ def test_a_withdrawn_job_unwinds_and_writes_nothing(self, tmp_path: Path) -> None:
+ config = _writing_to(Config(), tmp_path / "out")
+ reconstructor = Reconstructor(config, library=build_mini_library(config))
+ source = write_three_stem_recordings(config, tmp_path)[0]
+ stems = StemsConfig.single_entry(list(config.generation.channels))
+ jobs = GroupConversion(sources=(source,), stems=stems).jobs(config)
+ reporter: RecordingReporter[ReconstructionProgress] = RecordingReporter(withdraw_at=FIRST_REPORT)
+
+ with pytest.raises(OperationCanceled):
+ reconstruct_job((reconstructor, jobs[0], reporter))
+
+ assert not jobs[0].output_path.exists()
diff --git a/tests/integration/reconstruction/test_conversion_progress.py b/tests/integration/reconstruction/test_conversion_progress.py
new file mode 100644
index 000000000..a48df5471
--- /dev/null
+++ b/tests/integration/reconstruction/test_conversion_progress.py
@@ -0,0 +1,105 @@
+from contextlib import contextmanager
+from functools import partial
+from pathlib import Path
+from typing import Final, Iterator, Tuple
+from unittest.mock import patch
+
+import pytest
+
+from sampletones_core.configs import Config
+from sampletones_core.reconstructions.converter import GroupConversion, ReconstructionConverter
+from sampletones_core.reconstructions.reconstructor.stems.configs.config import StemsConfig
+from sampletones_core.reconstructions.stage import ReconstructionStage
+from tests.suite.conversion import FakeReconstructor, write_silent_recording
+from tests.suite.parallelization import ProgressRecorder, stands_partway
+
+RECONSTRUCTOR_PATCH: Final[str] = "sampletones_core.reconstructions.converter.converter.Reconstructor"
+READING_TIMEOUT: Final[float] = 60.0
+POOL_TIMEOUT: Final[float] = 120.0
+LONE_WORKER: Final[int] = 1
+ONE_JOB: Final[int] = 1
+WHOLE_RUN: Final[float] = 1.0
+RELEASE_NAME: Final[str] = "release"
+
+
+def _config(tmp_path: Path) -> Config:
+ """A configuration writing under the test's own directory, with one worker to run in."""
+ general = Config().general.model_copy(
+ update={"reconstructions_directory": str(tmp_path / "out"), "max_workers": LONE_WORKER}
+ )
+ return Config().model_copy(update={"general": general})
+
+
+@contextmanager
+def conversion_run(tmp_path: Path) -> Iterator[Tuple[ReconstructionConverter, ProgressRecorder, Path]]:
+ """Runs one conversion through the pool with the reconstruction itself standing in.
+
+ The release file is written on the way out whatever the test did, so a run whose assertion
+ failed before releasing its job still ends rather than holding a worker at its halfway mark.
+ """
+ config = _config(tmp_path)
+ source = write_silent_recording(tmp_path / "kick.wav")
+ release_path = tmp_path / RELEASE_NAME
+ stems = StemsConfig.single_entry(list(config.generation.channels))
+ plan = GroupConversion(sources=(source,), stems=stems)
+
+ recorder = ProgressRecorder()
+ converter = ReconstructionConverter(config=config, plan=plan)
+ converter.set_callbacks(on_progress=recorder)
+ standing_in = partial(FakeReconstructor, release_path=release_path)
+ with patch(RECONSTRUCTOR_PATCH, standing_in):
+ converter.start()
+ try:
+ yield converter, recorder, release_path
+ finally:
+ release_path.touch(exist_ok=True)
+ converter.shutdown()
+
+
+class TestASingleConversionReportsItself:
+ """One recording is one job, and the run says how far that job has come while it runs.
+
+ This is the whole path the reader watches: the run hands the job a line, the job walks its
+ stages over it from a worker process, and the run reads the stages back as one climbing
+ figure. The reconstruction itself stands in, since what is under test is the wiring.
+ """
+
+ def test_the_run_stands_between_its_ends_while_its_one_job_runs(self, tmp_path: Path) -> None:
+ with conversion_run(tmp_path) as (converter, recorder, release_path):
+ assert recorder.wait_for(stands_partway, READING_TIMEOUT)
+
+ release_path.touch()
+ converter.wait(POOL_TIMEOUT)
+
+ def test_the_run_names_the_recording_it_is_reading(self, tmp_path: Path) -> None:
+ with conversion_run(tmp_path) as (converter, recorder, release_path):
+ assert recorder.wait_for(stands_partway, READING_TIMEOUT)
+ release_path.touch()
+ converter.wait(POOL_TIMEOUT)
+
+ named = {progress.current_item for _, progress in recorder.readings if progress.current_item}
+
+ assert named == {str(tmp_path / "kick.wav")}
+
+ def test_the_stages_reach_the_run_as_the_job_named_them(self, tmp_path: Path) -> None:
+ """A reading is where the job stands, so a stage passed through in one step may be read
+ together with the one after it; what every reading carries is a stage the job named."""
+ with conversion_run(tmp_path) as (converter, recorder, release_path):
+ assert recorder.wait_for(stands_partway, READING_TIMEOUT)
+ release_path.touch()
+ converter.wait(POOL_TIMEOUT)
+
+ stages = {step.stage for step in recorder.steps}
+
+ assert stages <= {stage.value for stage in ReconstructionStage}
+ assert ReconstructionStage.MATCHING.value in stages
+
+ def test_the_run_climbs_to_its_end(self, tmp_path: Path) -> None:
+ with conversion_run(tmp_path) as (converter, recorder, release_path):
+ assert recorder.wait_for(stands_partway, READING_TIMEOUT)
+ release_path.touch()
+ converter.wait(POOL_TIMEOUT)
+
+ fractions = recorder.fractions
+ assert fractions == sorted(fractions)
+ assert max(fractions) == pytest.approx(WHOLE_RUN)
diff --git a/tests/integration/reconstruction/test_decoding.py b/tests/integration/reconstruction/test_decoding.py
new file mode 100644
index 000000000..62a772a1c
--- /dev/null
+++ b/tests/integration/reconstruction/test_decoding.py
@@ -0,0 +1,88 @@
+from pathlib import Path
+from typing import Dict, Final, List
+
+import numpy as np
+
+from sampletones_core.audio import write_wave
+from sampletones_core.configs import Config
+from sampletones_core.configs.generation import GenerationConfig
+from sampletones_core.constants.enums import ChannelName, SelectorName
+from sampletones_core.instructions import InstructionUnion
+from sampletones_core.reconstructions import Reconstruction, Reconstructor
+from tests.integration.assets.reconstruction import build_mini_library
+
+_DURATION_SECONDS: Final[float] = 1.0
+_LOWER_TONE: Final[float] = 440.0
+_UPPER_TONE: Final[float] = 661.0
+_NOISE_LEVEL: Final[float] = 0.08
+_NOISE_SEED: Final[int] = 17
+
+
+def _flickering_path(tmp_path: Path, config: Config) -> Path:
+ """A two-tone target under light noise, so the cheapest candidate wavers frame to frame."""
+ sample_rate = config.library.sample_rate
+ count = int(sample_rate * _DURATION_SECONDS)
+ time = np.arange(count) / sample_rate
+ audio = 0.5 * np.sin(2 * np.pi * _LOWER_TONE * time) + 0.25 * np.sin(2 * np.pi * _UPPER_TONE * time)
+ audio += np.random.default_rng(_NOISE_SEED).normal(0.0, _NOISE_LEVEL, count)
+
+ path = tmp_path / "flicker.wav"
+ write_wave(path, sample_rate, audio)
+ return path
+
+
+def _reconstruct(selector_name: SelectorName, audio_path: Path) -> Reconstruction:
+ config = Config(generation=GenerationConfig(decoder={"selector": selector_name}))
+ reconstruction = Reconstructor(config, library=build_mini_library(config))(audio_path)
+ assert reconstruction is not None
+ return reconstruction
+
+
+def _changes(stream: List[InstructionUnion]) -> int:
+ return sum(1 for previous, current in zip(stream, stream[1:]) if previous != current)
+
+
+def _change_counts(reconstruction: Reconstruction) -> Dict[ChannelName, int]:
+ return {
+ channel_name: _changes(reconstruction.instructions[channel_name])
+ for channel_name in reconstruction.playing_channels
+ }
+
+
+class TestConfiguredDecoderReachesTheConversion:
+ """The decoder named in the configuration is the one a conversion is decoded with."""
+
+ def test_the_two_decoders_answer_the_same_target_differently(self, tmp_path: Path) -> None:
+ audio_path = _flickering_path(tmp_path, Config())
+
+ greedy = _reconstruct(SelectorName.GREEDY, audio_path)
+ viterbi = _reconstruct(SelectorName.VITERBI, audio_path)
+
+ assert set(greedy.playing_channels) == set(viterbi.playing_channels)
+ assert any(
+ greedy.instructions[channel_name] != viterbi.instructions[channel_name]
+ for channel_name in greedy.playing_channels
+ )
+
+ def test_continuity_decoding_holds_instructions_longer(self, tmp_path: Path) -> None:
+ """Weighing transitions is what buys steadiness, so the decoded streams change less often."""
+ audio_path = _flickering_path(tmp_path, Config())
+
+ greedy = _reconstruct(SelectorName.GREEDY, audio_path)
+ viterbi = _reconstruct(SelectorName.VITERBI, audio_path)
+
+ assert sum(_change_counts(viterbi).values()) < sum(_change_counts(greedy).values())
+
+ def test_a_decoder_answers_every_frame_of_every_channel(self, tmp_path: Path) -> None:
+ audio_path = _flickering_path(tmp_path, Config())
+
+ for selector_name in SelectorName:
+ reconstruction = _reconstruct(selector_name, audio_path)
+ frame_counts = {
+ channel_name: len(reconstruction.instructions[channel_name])
+ for channel_name in reconstruction.playing_channels
+ }
+ assert len(set(frame_counts.values())) == 1
+ assert set(reconstruction.stems_data.assignments_by_channel) == set(reconstruction.playing_channels)
+ for channel_name, stem_ids in reconstruction.stems_data.assignments_by_channel.items():
+ assert len(stem_ids) == frame_counts[channel_name]
diff --git a/tests/integration/reconstruction/test_pitch_refinement.py b/tests/integration/reconstruction/test_pitch_refinement.py
new file mode 100644
index 000000000..22c1fb87b
--- /dev/null
+++ b/tests/integration/reconstruction/test_pitch_refinement.py
@@ -0,0 +1,262 @@
+import math
+from pathlib import Path
+from typing import Any, Dict, Final, List
+
+import numpy as np
+import pytest
+
+from sampletones_core.audio import write_wave
+from sampletones_core.configs import Config
+from sampletones_core.configs.generation import GenerationConfig, RefinementConfig
+from sampletones_core.constants.enums import ChannelName, FeatureKey
+from sampletones_core.constants.general import MAX_VOLUME
+from sampletones_core.fft import Window
+from sampletones_core.fft.features import get_feature_extractor
+from sampletones_core.fft.instantaneous import InstantaneousPitch
+from sampletones_core.generators import PulseGenerator, get_generators_by_channels
+from sampletones_core.instructions import InstructionUnion, PulseInstruction
+from sampletones_core.library import (
+ InstructionLibrary,
+ InstructionLibraryData,
+ InstructionLibraryFragment,
+)
+from sampletones_core.reconstructions import Reconstruction, Reconstructor
+from sampletones_core.reconstructions.reconstructor.refinement import refiner
+
+PITCH: Final[int] = 60
+NEIGHBORHOOD: Final[range] = range(PITCH - 2, PITCH + 3)
+DUTY_CYCLE: Final[int] = 2
+SECONDS: Final[float] = 1.0
+CENTS_PER_OCTAVE: Final[float] = 1200.0
+DETUNE_CENTS: Final[float] = 30.0
+CENTS_TOLERANCE: Final[float] = 8.0
+NOISE_SEED: Final[int] = 11
+EDGE_FRAMES: Final[int] = 1
+PULSE_ONLY: Final[List[ChannelName]] = [ChannelName.PULSE1]
+
+
+def _config(*, refinement: RefinementConfig) -> Config:
+ return Config(generation=GenerationConfig(channels=PULSE_ONLY, refinement=refinement))
+
+
+def _library(config: Config) -> InstructionLibrary:
+ """A catalog of the notes around the target, at every volume the pulse channel offers.
+
+ Nothing in it is bent: the catalog is the equal-tempered grid the matching chooses from, and
+ the bend is what the refinement adds on top of that choice.
+ """
+ window = Window.from_config(config)
+ extractor = get_feature_extractor(config, window)
+ generator = get_generators_by_channels(config, PULSE_ONLY)[ChannelName.PULSE1]
+
+ data: Dict[InstructionUnion, InstructionLibraryFragment[Any]] = {}
+ for instruction in generator.get_possible_instructions():
+ if instruction.on and instruction.pitch not in NEIGHBORHOOD:
+ continue
+
+ if instruction.on and instruction.duty_cycle != DUTY_CYCLE:
+ continue
+
+ data[instruction] = InstructionLibraryFragment.create(generator, instruction, extractor)
+
+ library = InstructionLibrary()
+ library.data[library.create_key(config, window)] = InstructionLibraryData.create(config, data)
+ return library
+
+
+def _square_path(path: Path, config: Config, cents: float, seconds: float = SECONDS) -> Path:
+ """A steady square tone standing ``cents`` off the note the catalog holds."""
+ sample_rate = config.library.sample_rate
+ generator = PulseGenerator(config, ChannelName.PULSE1)
+ frequency = generator.sounds_at(PITCH, 0) * 2 ** (cents / CENTS_PER_OCTAVE)
+
+ count = int(sample_rate * seconds)
+ phase = (np.arange(count) * frequency / sample_rate) % 1.0
+ audio = np.where(phase < 0.5, 0.4, -0.4)
+
+ write_wave(path, sample_rate, audio)
+ return path
+
+
+def _noise_path(path: Path, config: Config) -> Path:
+ sample_rate = config.library.sample_rate
+ count = int(sample_rate * SECONDS)
+ audio = np.random.default_rng(NOISE_SEED).normal(0.0, 0.3, count)
+
+ write_wave(path, sample_rate, audio)
+ return path
+
+
+def _reconstruct(config: Config, audio_path: Path) -> Reconstruction:
+ reconstruction = Reconstructor(config, library=_library(config))(audio_path)
+ assert reconstruction is not None
+ return reconstruction
+
+
+def _sounding(reconstruction: Reconstruction) -> List[PulseInstruction]:
+ return [
+ instruction
+ for instruction in reconstruction.instructions[ChannelName.PULSE1]
+ if isinstance(instruction, PulseInstruction) and instruction.on
+ ]
+
+
+def _cents_off(config: Config, instruction: PulseInstruction) -> float:
+ """How far the frame sounds from the note it names, in cents."""
+ generator = PulseGenerator(config, ChannelName.PULSE1)
+ bent = generator.sounds_at(instruction.pitch, instruction.timer_offset)
+ named = generator.sounds_at(instruction.pitch, 0)
+ return CENTS_PER_OCTAVE * math.log2(bent / named)
+
+
+@pytest.fixture(scope="module")
+def refining() -> Config:
+ return _config(refinement=RefinementConfig(enabled=True))
+
+
+@pytest.fixture(scope="module")
+def plain() -> Config:
+ return _config(refinement=RefinementConfig(enabled=False))
+
+
+class TestAConversionLandsOnTheNoteTheSourceSounds:
+ """A source recorded off the equal-tempered grid comes back bent onto its own tuning.
+
+ The catalog holds whole semitones, so the matching can only reach the nearest note; the
+ hardware's divider grid is finer than that, and the refinement is what spends the difference.
+ """
+
+ def test_a_detuned_tone_comes_back_bent_towards_its_own_pitch(
+ self,
+ refining: Config,
+ tmp_path: Path,
+ ) -> None:
+ reconstruction = _reconstruct(refining, _square_path(tmp_path / "sharp.wav", refining, DETUNE_CENTS))
+
+ sounding = _sounding(reconstruction)
+ assert sounding
+ measured = float(np.median([_cents_off(refining, frame) for frame in sounding]))
+
+ assert abs(measured - DETUNE_CENTS) < CENTS_TOLERANCE
+
+ def test_a_tone_already_on_the_grid_is_left_where_it_is(
+ self,
+ refining: Config,
+ tmp_path: Path,
+ ) -> None:
+ reconstruction = _reconstruct(refining, _square_path(tmp_path / "flat.wav", refining, 0.0))
+
+ sounding = _sounding(reconstruction)
+ assert sounding
+ measured = float(np.median([_cents_off(refining, frame) for frame in sounding]))
+
+ assert abs(measured) < CENTS_TOLERANCE
+
+ def test_the_bend_holds_rather_than_wandering(self, refining: Config, tmp_path: Path) -> None:
+ """A steady tone is one tuning, so the stream settles on one bend and keeps it.
+
+ The opening and closing frames are read across the edge of the recording, where the
+ constant-Q window reaches past what was recorded, so the interior is what a held tuning
+ shows in.
+ """
+ reconstruction = _reconstruct(refining, _square_path(tmp_path / "steady.wav", refining, DETUNE_CENTS))
+
+ bends = [frame.timer_offset for frame in _sounding(reconstruction)]
+ interior = bends[EDGE_FRAMES:-EDGE_FRAMES]
+
+ assert interior
+ assert len(set(interior)) == 1
+
+
+class TestWhatTheRefinementLeavesAlone:
+ def test_a_conversion_with_the_refinement_off_bends_nothing(
+ self,
+ plain: Config,
+ tmp_path: Path,
+ ) -> None:
+ reconstruction = _reconstruct(plain, _square_path(tmp_path / "unrefined.wav", plain, DETUNE_CENTS))
+
+ assert all(not frame.bent for frame in _sounding(reconstruction))
+
+ def test_a_conversion_with_the_refinement_off_records_the_bend_as_the_channels(
+ self,
+ plain: Config,
+ tmp_path: Path,
+ ) -> None:
+ """Nothing bent means nothing chosen, so both dimensions stay the channel's own."""
+ reconstruction = _reconstruct(plain, _square_path(tmp_path / "held.wav", plain, DETUNE_CENTS))
+ held = reconstruction.held_features[ChannelName.PULSE1]
+
+ assert FeatureKey.PITCH in held
+ assert FeatureKey.HI_PITCH in held
+
+ def test_a_source_with_no_pitch_to_read_is_left_unbent(
+ self,
+ refining: Config,
+ tmp_path: Path,
+ ) -> None:
+ """Noise states no fundamental, so no frame of it earns a bend."""
+ reconstruction = _reconstruct(refining, _noise_path(tmp_path / "noise.wav", refining))
+
+ assert all(not frame.bent for frame in _sounding(reconstruction))
+
+
+class TestWhatTheRefinementReads:
+ """The refinement reads a recording rather than searching it.
+
+ What it adds to a conversion is one transform per stem and a walk over the frames: no
+ candidate is enumerated and nothing is rescored. Counting the readings holds that where a
+ clock cannot — what one transform costs is the machine's own, and a CUDA build and a CPU
+ build disagree about it by two orders of magnitude, so a reading per stem is the claim worth
+ pinning.
+ """
+
+ @staticmethod
+ def _counted(monkeypatch: pytest.MonkeyPatch) -> List[int]:
+ """The frames of each recording the refinement reads, in the order it reads them."""
+ readings: List[int] = []
+
+ def counting(recording: np.ndarray, sample_rate: int, hop_length: int) -> InstantaneousPitch:
+ readings.append(len(recording))
+ return InstantaneousPitch(recording, sample_rate, hop_length)
+
+ monkeypatch.setattr(refiner, "InstantaneousPitch", counting)
+ return readings
+
+ def test_a_refined_conversion_reads_each_recording_once(
+ self,
+ refining: Config,
+ tmp_path: Path,
+ monkeypatch: pytest.MonkeyPatch,
+ ) -> None:
+ readings = self._counted(monkeypatch)
+ _reconstruct(refining, _square_path(tmp_path / "read.wav", refining, DETUNE_CENTS))
+
+ assert len(readings) == 1
+
+ def test_a_longer_source_is_read_no_more_often(
+ self,
+ refining: Config,
+ tmp_path: Path,
+ monkeypatch: pytest.MonkeyPatch,
+ ) -> None:
+ """A reading per stem is what keeps the cost off the frame count and off the catalog."""
+ readings = self._counted(monkeypatch)
+ _reconstruct(refining, _square_path(tmp_path / "short.wav", refining, DETUNE_CENTS))
+ short = len(readings)
+
+ readings.clear()
+ _reconstruct(refining, _square_path(tmp_path / "long.wav", refining, DETUNE_CENTS, seconds=SECONDS * 3))
+
+ assert len(readings) == short
+
+ def test_a_conversion_with_the_refinement_off_reads_nothing(
+ self,
+ plain: Config,
+ tmp_path: Path,
+ monkeypatch: pytest.MonkeyPatch,
+ ) -> None:
+ readings = self._counted(monkeypatch)
+ _reconstruct(plain, _square_path(tmp_path / "unread.wav", plain, DETUNE_CENTS))
+
+ assert not readings
diff --git a/tests/integration/reconstruction/test_stems_reconstruction.py b/tests/integration/reconstruction/test_stems_reconstruction.py
new file mode 100644
index 000000000..89e5fc1a5
--- /dev/null
+++ b/tests/integration/reconstruction/test_stems_reconstruction.py
@@ -0,0 +1,598 @@
+from pathlib import Path
+from typing import AbstractSet, Dict, Final, List, Sequence, Tuple
+
+import numpy as np
+import pytest
+
+from sampletones_application.logic.reconstruction.data import ReconstructionData
+from sampletones_core.audio import mix, write_wave
+from sampletones_core.configs import Config
+from sampletones_core.constants.algorithm import DEFAULT_STEMS_CHANNEL_CAP, RESTING_STEM_ID
+from sampletones_core.constants.enums import ChannelName, HierarchyMode
+from sampletones_core.reconstructions import Reconstruction, Reconstructor
+from sampletones_core.reconstructions.reconstruction.stems.removal import without_stem
+from sampletones_core.reconstructions.reconstruction.stems.selection import StemSelection
+from sampletones_core.reconstructions.reconstructor.stems.configs.config import StemsConfig
+from sampletones_core.reconstructions.reconstructor.stems.configs.entry import StemEntry
+from sampletones_core.reconstructions.reconstructor.stems.configs.hierarchy import StemsHierarchy
+from tests.integration.assets.reconstruction import (
+ STEM_A_ID,
+ STEM_B_ID,
+ STEM_C_ID,
+ STEM_RECORDING_DURATION_SECONDS,
+ build_mini_library,
+ three_stem_config,
+ three_stem_reconstruction_config,
+ write_three_stem_recordings,
+)
+
+_TONE_FREQUENCY: Final[float] = 440.0
+_DURATION_SECONDS: Final[float] = 0.5
+_MIX_TOLERANCE: Final[float] = 1e-6 # float32 sums drift with accumulation order
+_DISJOINT_DURATION_SECONDS: Final[float] = 0.9
+_DISJOINT_TONES: Final[Tuple[float, ...]] = (220.0, 440.0, 880.0)
+_DISJOINT_AMPLITUDE: Final[float] = 0.5
+
+
+def _frame_count(config: Config, duration_seconds: float) -> int:
+ return int(config.library.sample_rate * duration_seconds) // config.library.frame_length
+
+
+def _stems_config() -> StemsConfig:
+ return StemsConfig(
+ entries=[
+ StemEntry(id=0, channels=[ChannelName.PULSE1]),
+ StemEntry(id=1, channels=[ChannelName.NOISE]),
+ ],
+ hierarchy=StemsHierarchy(
+ levels=[[0], [1]],
+ mode=HierarchyMode.STRICT,
+ ),
+ channel_cap=1,
+ )
+
+
+class TestReconstructStems:
+ def test_assigns_disjoint_stems_to_their_channels(self, tmp_path: Path) -> None:
+ config = Config()
+ library = build_mini_library(config)
+ reconstructor = Reconstructor(config, library=library)
+
+ sample_rate = config.library.sample_rate
+ count = int(sample_rate * _DURATION_SECONDS)
+ time = np.arange(count) / sample_rate
+ tone = 0.5 * np.sin(2 * np.pi * _TONE_FREQUENCY * time)
+ rng = np.random.default_rng(93)
+ noise = rng.uniform(-0.3, 0.3, count)
+
+ tone_path = tmp_path / "tone.wav"
+ noise_path = tmp_path / "noise.wav"
+ write_wave(tone_path, sample_rate, tone)
+ write_wave(noise_path, sample_rate, noise)
+
+ reconstruction = reconstructor.reconstruct(
+ [tone_path, noise_path],
+ _stems_config(),
+ )
+
+ assert reconstruction is not None
+ assert reconstruction.audio_filepath == (tone_path, noise_path)
+ assert reconstruction.stems_data is not None
+ stems_data = reconstruction.stems_data
+ assert stems_data.config == _stems_config()
+ assert {entry.id for entry in stems_data.config.entries} == {0, 1}
+
+ assignments = stems_data.assignments_by_channel
+ assert set(assignments) == {
+ ChannelName.PULSE1,
+ ChannelName.NOISE,
+ }
+ assert set(assignments[ChannelName.PULSE1]) == {0}
+ assert set(assignments[ChannelName.NOISE]) == {1}
+
+ frame_count = count // config.library.frame_length
+ assert len(assignments[ChannelName.PULSE1]) == frame_count
+ assert len(assignments[ChannelName.NOISE]) == frame_count
+ assert len(reconstruction.instructions[ChannelName.PULSE1]) == frame_count
+ assert len(reconstruction.instructions[ChannelName.NOISE]) == frame_count
+
+ def test_requires_one_path_per_entry(self, tmp_path: Path) -> None:
+ config = Config()
+ library = build_mini_library(config)
+ reconstructor = Reconstructor(config, library=library)
+
+ with pytest.raises(ValueError, match="stem paths"):
+ reconstructor.reconstruct(
+ [tmp_path / "only_one.wav"],
+ _stems_config(),
+ )
+
+
+class TestThreeStemHierarchy:
+ """The shared three-stem example: a (pulse 1, triangle, noise) and b (pulse 2,
+ triangle) pick on the first hierarchy level, c (pulse 1, noise) on the second."""
+
+ def test_builds_a_reconstruction_over_the_three_stems(self, tmp_path: Path) -> None:
+ config = three_stem_reconstruction_config()
+ library = build_mini_library(config)
+ reconstructor = Reconstructor(config, library=library)
+ stems_config = three_stem_config()
+ paths = write_three_stem_recordings(config, tmp_path)
+
+ reconstruction = reconstructor.reconstruct(list(paths), stems_config)
+
+ assert reconstruction is not None
+ assert reconstruction.audio_filepath == paths
+ stems_data = reconstruction.stems_data
+ assert stems_data is not None
+ assert stems_data.config == stems_config
+
+ assignments = stems_data.assignments_by_channel
+ assert assignments
+ holders = {
+ ChannelName.PULSE2: {STEM_B_ID},
+ ChannelName.TRIANGLE: {STEM_A_ID, STEM_B_ID},
+ ChannelName.PULSE1: {STEM_A_ID, STEM_C_ID},
+ ChannelName.NOISE: {STEM_A_ID, STEM_C_ID},
+ }
+ for channel, allowed in holders.items():
+ assert set(assignments.get(channel, [])) - {RESTING_STEM_ID} <= allowed
+
+ for channel, stem_ids in assignments.items():
+ assert len(reconstruction.instructions[channel]) == len(stem_ids)
+
+ def test_every_channel_in_play_carries_one_entry_per_frame(self, tmp_path: Path) -> None:
+ """A cap leaves channels unclaimed, and each of them rests rather than dropping its frame.
+
+ Streams that skipped a frame would carry their later frames early, so what a channel plays
+ would drift out of step with the recording it was matched against.
+ """
+ config = three_stem_reconstruction_config()
+ library = build_mini_library(config)
+ reconstructor = Reconstructor(config, library=library)
+ stems_config = three_stem_config()
+ paths = write_three_stem_recordings(config, tmp_path)
+
+ reconstruction = reconstructor.reconstruct(list(paths), stems_config)
+
+ assert reconstruction is not None
+ frame_count = _frame_count(config, STEM_RECORDING_DURATION_SECONDS)
+ assignments = reconstruction.stems_data.assignments_by_channel
+ assert set(assignments) == set(reconstruction.playing_channels)
+
+ for channel, stem_ids in assignments.items():
+ assert len(stem_ids) == frame_count
+ assert len(reconstruction.instructions[channel]) == frame_count
+ assert len(reconstruction.approximations[channel]) == frame_count * config.library.frame_length
+
+ picks_per_frame = [
+ sum(stem_ids[frame] != RESTING_STEM_ID for stem_ids in assignments.values()) for frame in range(frame_count)
+ ]
+ assert picks_per_frame == [len(stems_config.entries)] * frame_count
+
+ def test_round_trips_through_the_file(self, tmp_path: Path) -> None:
+ config = three_stem_reconstruction_config()
+ library = build_mini_library(config)
+ reconstructor = Reconstructor(config, library=library)
+ stems_config = three_stem_config()
+ paths = write_three_stem_recordings(config, tmp_path)
+ reconstruction = reconstructor.reconstruct(list(paths), stems_config)
+ assert reconstruction is not None
+
+ save_path = tmp_path / "three_stems.stn"
+ reconstruction.save(save_path)
+
+ loaded = Reconstruction.load(save_path)
+
+ assert loaded.audio_filepath == paths
+ assert loaded.stems_data is not None
+ assert loaded.stems_data.config == stems_config
+ assert loaded.stems_data.assignments_by_channel == reconstruction.stems_data.assignments_by_channel
+
+ def test_selection_filters_the_waveform_and_partials(self, tmp_path: Path) -> None:
+ """Selecting stems zeroes the unselected stems' frames end to end.
+
+ The loaded document projects the selection: each channel's waveform carries only the
+ selected stems' frames, the partials sum them, the original plays the selected
+ recordings, and a full selection answers the unfiltered audio.
+ """
+ config = three_stem_reconstruction_config()
+ library = build_mini_library(config)
+ reconstructor = Reconstructor(config, library=library)
+ stems_config = three_stem_config()
+ paths = write_three_stem_recordings(config, tmp_path)
+ reconstruction = reconstructor.reconstruct(list(paths), stems_config)
+ assert reconstruction is not None
+
+ save_path = tmp_path / "three_stems.stn"
+ reconstruction.save(save_path)
+ data = ReconstructionData.load(save_path)
+ stems_data = data.reconstruction.stems_data
+ assert stems_data is not None
+
+ frame_length = config.library.frame_length
+ all_stem_ids = {entry.id for entry in stems_data.config.entries}
+ channels = list(stems_data.assignments_by_channel)
+
+ def masked_channels(selected: AbstractSet[int]) -> Dict[ChannelName, np.ndarray]:
+ """The per-channel ground truth: the frames whose recorded stem id is selected."""
+ masked: Dict[ChannelName, np.ndarray] = {}
+ for channel, stem_ids in stems_data.assignments_by_channel.items():
+ channel_audio = data.reconstruction.approximations[channel]
+ frames = np.zeros_like(channel_audio)
+ for frame_index, stem_id in enumerate(stem_ids):
+ if stem_id in selected:
+ start = frame_index * frame_length
+ frames[start : start + frame_length] = channel_audio[start : start + frame_length]
+ masked[channel] = frames
+ return masked
+
+ def heard(selected: AbstractSet[int]) -> StemSelection:
+ """The selection hearing the named stems on every channel."""
+ return StemSelection.everywhere(frozenset(selected), channels)
+
+ unfiltered = data.waveform_data()
+ full = data.waveform_data(heard(all_stem_ids))
+ np.testing.assert_allclose(full.approximation, unfiltered.approximation, atol=_MIX_TOLERANCE)
+ np.testing.assert_allclose(full.original_audio, unfiltered.original_audio, atol=_MIX_TOLERANCE)
+ np.testing.assert_allclose(
+ data.partials_for(channels, heard(all_stem_ids)),
+ data.get_partials(channels),
+ atol=_MIX_TOLERANCE,
+ )
+
+ for selected_id in (STEM_A_ID, STEM_B_ID, STEM_C_ID):
+ selected = frozenset({selected_id})
+ expected = masked_channels(selected)
+ waveform = data.waveform_data(heard(selected))
+
+ for channel, expected_audio in expected.items():
+ np.testing.assert_array_equal(waveform.approximations[channel], expected_audio)
+ np.testing.assert_allclose(waveform.approximation, mix(list(expected.values())), atol=_MIX_TOLERANCE)
+ np.testing.assert_allclose(
+ data.partials_for(channels, heard(selected)),
+ mix(list(expected.values())),
+ atol=_MIX_TOLERANCE,
+ )
+ np.testing.assert_allclose(
+ data.original_mix_for(heard(selected)), data.stem_audios[selected_id], atol=_MIX_TOLERANCE
+ )
+
+ selected = frozenset()
+ waveform = data.waveform_data(heard(selected))
+ for channel in stems_data.assignments_by_channel:
+ np.testing.assert_array_equal(
+ waveform.approximations[channel],
+ np.zeros_like(data.reconstruction.approximations[channel]),
+ )
+ np.testing.assert_allclose(waveform.approximation, np.zeros_like(data.reconstruction.approximation))
+ np.testing.assert_allclose(
+ data.partials_for(channels, heard(selected)),
+ np.zeros_like(data.reconstruction.approximation),
+ )
+ np.testing.assert_allclose(
+ data.original_mix_for(heard(selected)),
+ np.zeros_like(data.reconstruction.approximation),
+ )
+
+
+class TestRemovingAStem:
+ """The shared three-stem example with one recording taken out of the document for good."""
+
+ def _three_stems(self, tmp_path: Path) -> Tuple[Reconstruction, Tuple[Path, Path, Path], Config]:
+ config = three_stem_reconstruction_config()
+ library = build_mini_library(config)
+ reconstructor = Reconstructor(config, library=library)
+ paths = write_three_stem_recordings(config, tmp_path)
+ reconstruction = reconstructor.reconstruct(list(paths), three_stem_config())
+ assert reconstruction is not None
+ return reconstruction, paths, config
+
+ def test_the_removed_recording_leaves_the_setup_and_the_source_paths(self, tmp_path: Path) -> None:
+ reconstruction, paths, _config = self._three_stems(tmp_path)
+
+ remaining = without_stem(reconstruction, STEM_C_ID)
+
+ assert [entry.id for entry in remaining.stems_data.config.entries] == [STEM_A_ID, STEM_B_ID]
+ assert remaining.stems_data.config.hierarchy.levels == [[STEM_A_ID, STEM_B_ID]]
+ assert remaining.audio_filepath == (paths[0], paths[1])
+
+ def test_the_frames_it_held_fall_silent_while_the_rest_stand(self, tmp_path: Path) -> None:
+ """A removal touches the removed recording's frames alone, sample for sample."""
+ reconstruction, _paths, config = self._three_stems(tmp_path)
+ frame_length = config.library.frame_length
+ assignments = reconstruction.stems_data.assignments_by_channel
+ before = {channel: audio.copy() for channel, audio in reconstruction.approximations.items()}
+
+ remaining = without_stem(reconstruction, STEM_C_ID)
+
+ for channel, stem_ids in assignments.items():
+ audio = remaining.approximations[channel]
+ for frame_index, stem_id in enumerate(stem_ids):
+ span = slice(frame_index * frame_length, (frame_index + 1) * frame_length)
+ expected = np.zeros(frame_length, dtype=np.float32) if stem_id == STEM_C_ID else before[channel][span]
+ np.testing.assert_array_equal(audio[span], expected)
+
+ assert remaining.stems_data.assignments_by_channel[channel] == [
+ RESTING_STEM_ID if stem_id == STEM_C_ID else stem_id for stem_id in stem_ids
+ ]
+
+ def test_the_channels_it_alone_held_stand_by(self, tmp_path: Path) -> None:
+ """A channel every remaining recording passes over describes no frame at all.
+
+ The channels stem c alone sounded come from the recorded assignment, so the test states
+ what a removal does to them rather than which channels this material happened to reach.
+ """
+ reconstruction, _paths, _config = self._three_stems(tmp_path)
+ assignments = reconstruction.stems_data.assignments_by_channel
+ held_alone = tuple(
+ channel for channel, stem_ids in assignments.items() if set(stem_ids) - {RESTING_STEM_ID} == {STEM_C_ID}
+ )
+ assert held_alone
+
+ remaining = without_stem(reconstruction, STEM_C_ID)
+
+ assert set(remaining.playing_channels) == set(assignments) - set(held_alone)
+ for channel in held_alone:
+ np.testing.assert_array_equal(
+ remaining.approximations[channel],
+ np.zeros_like(reconstruction.approximations[channel]),
+ )
+ assert set(remaining.stems_data.assignments_by_channel[channel]) == {RESTING_STEM_ID}
+
+ def test_the_reduced_reconstruction_round_trips_through_the_file(self, tmp_path: Path) -> None:
+ reconstruction, _paths, _config = self._three_stems(tmp_path)
+ remaining = without_stem(reconstruction, STEM_B_ID)
+ save_path = tmp_path / "two_stems.stn"
+
+ remaining.save(save_path)
+ loaded = Reconstruction.load(save_path)
+
+ assert [entry.id for entry in loaded.stems_data.config.entries] == [STEM_A_ID, STEM_C_ID]
+ assert loaded.stems_data.config.hierarchy.levels == [[STEM_A_ID], [STEM_C_ID]]
+ np.testing.assert_allclose(loaded.approximation, remaining.approximation, atol=_MIX_TOLERANCE)
+
+ def test_what_stays_plays_as_it_did_before(self, tmp_path: Path) -> None:
+ """A removal leaves the same audio the reader heard while listening to the stems that stay."""
+ reconstruction, _paths, _config = self._three_stems(tmp_path)
+ data = ReconstructionData.from_reconstruction(reconstruction, name="three")
+ channels = list(reconstruction.stems_data.assignments_by_channel)
+ heard_before = data.waveform_data(
+ StemSelection.everywhere(frozenset({STEM_A_ID, STEM_B_ID}), channels)
+ ).approximation
+
+ remaining = without_stem(reconstruction, STEM_C_ID)
+ reduced = ReconstructionData.from_reconstruction(remaining, name="two")
+ heard_after = reduced.waveform_data(
+ StemSelection.everywhere(frozenset({STEM_A_ID, STEM_B_ID}), channels)
+ ).approximation
+
+ np.testing.assert_allclose(heard_after, heard_before, atol=_MIX_TOLERANCE)
+
+
+class TestStemsOriginalAudio:
+ def test_mixes_the_recorded_stems_at_the_balance_they_were_captured_in(self, tmp_path: Path) -> None:
+ """The recordings reach the original at the levels they hold relative to one another.
+
+ The set is scaled by one factor drawn from its mix, so the ratio between two recordings
+ survives loading and the mix reaches the full range.
+ """
+ config = Config()
+ library = build_mini_library(config)
+ reconstructor = Reconstructor(config, library=library)
+
+ sample_rate = config.library.sample_rate
+ count = int(sample_rate * _DURATION_SECONDS)
+ time = np.arange(count) / sample_rate
+ tone = 0.5 * np.sin(2 * np.pi * _TONE_FREQUENCY * time)
+ rng = np.random.default_rng(93)
+ noise = rng.uniform(-0.3, 0.3, count)
+
+ tone_path = tmp_path / "tone.wav"
+ noise_path = tmp_path / "noise.wav"
+ write_wave(tone_path, sample_rate, tone)
+ write_wave(noise_path, sample_rate, noise)
+
+ reconstruction = reconstructor.reconstruct(
+ [tone_path, noise_path],
+ _stems_config(),
+ )
+ assert reconstruction is not None
+
+ save_path = tmp_path / "stems.stn"
+ reconstruction.save(save_path)
+
+ data = ReconstructionData.load(save_path)
+
+ assert data.reconstruction.audio_filepath == (tone_path, noise_path)
+ assert data.name == tmp_path.name
+ assert data.original_audio is not None
+ loaded_tone, loaded_noise = data.stem_audios
+ np.testing.assert_allclose(
+ np.max(np.abs(loaded_tone)) / np.max(np.abs(loaded_noise)),
+ np.max(np.abs(tone)) / np.max(np.abs(noise)),
+ rtol=1e-5,
+ )
+ np.testing.assert_allclose(data.original_audio, mix([loaded_tone, loaded_noise]), atol=_MIX_TOLERANCE)
+ np.testing.assert_allclose(np.max(np.abs(data.original_audio)), 1.0, rtol=1e-5)
+
+
+class TestClassicRunCarriesTheSingleEntryRecord:
+ """The classic single-file run is the stems pipeline's one-stem case."""
+
+ def _tone_path(self, tmp_path: Path, config: Config) -> Path:
+ sample_rate = config.library.sample_rate
+ count = int(sample_rate * _DURATION_SECONDS)
+ time = np.arange(count) / sample_rate
+ tone = 0.5 * np.sin(2 * np.pi * _TONE_FREQUENCY * time)
+ tone_path = tmp_path / "tone.wav"
+ write_wave(tone_path, sample_rate, tone)
+ return tone_path
+
+ def test_classic_conversion_records_one_stem_over_every_enabled_channel(self, tmp_path: Path) -> None:
+ config = Config()
+ library = build_mini_library(config)
+ reconstructor = Reconstructor(config, library=library)
+ tone_path = self._tone_path(tmp_path, config)
+
+ reconstruction = reconstructor(tone_path)
+
+ assert reconstruction is not None
+ assert reconstruction.audio_filepath == (tone_path,)
+ stems_data = reconstruction.stems_data
+ assert stems_data.config.entries[0].id == 0
+ assert stems_data.config.entries[0].channels == list(config.generation.channels)
+ assert stems_data.config.channel_cap == DEFAULT_STEMS_CHANNEL_CAP
+ for channel, stem_ids in stems_data.assignments_by_channel.items():
+ assert set(stem_ids) <= {0}
+ assert len(stem_ids) == len(reconstruction.instructions[channel])
+
+ def test_a_silent_stretch_rests_and_states_silence(self, tmp_path: Path) -> None:
+ """A source below the quietest renderable note leaves its channels resting there.
+
+ The frames it does sound in are answered as they always were, so the run keeps its shape
+ while the silence it holds reaches the streams as silence.
+ """
+ config = Config()
+ library = build_mini_library(config)
+ reconstructor = Reconstructor(config, library=library)
+
+ sample_rate = config.library.sample_rate
+ frame_length = config.library.frame_length
+ frames = int(sample_rate * _DURATION_SECONDS) // frame_length
+ sounding = range(frames // 2)
+ audio = np.zeros(frames * frame_length, dtype=np.float32)
+ span = slice(0, len(sounding) * frame_length)
+ time = np.arange(span.stop) / sample_rate
+ audio[span] = _DISJOINT_AMPLITUDE * np.sin(2 * np.pi * _TONE_FREQUENCY * time)
+ tone_path = tmp_path / "half_silent.wav"
+ write_wave(tone_path, sample_rate, audio)
+
+ reconstruction = reconstructor(tone_path)
+
+ assert reconstruction is not None
+ for channel, stem_ids in reconstruction.stems_data.assignments_by_channel.items():
+ assert set(stem_ids[: len(sounding)]) == {0}
+ assert set(stem_ids[len(sounding) :]) == {RESTING_STEM_ID}
+ for frame in range(len(sounding), frames):
+ assert not reconstruction.instructions[channel][frame].on
+ np.testing.assert_array_equal(
+ reconstruction.approximations[channel][frame * frame_length : (frame + 1) * frame_length],
+ np.zeros(frame_length, dtype=np.float32),
+ )
+
+ def test_a_cap_of_one_leaves_every_frame_to_one_channel(self, tmp_path: Path) -> None:
+ """One channel sounds per frame while the others rest, each keeping its place in the frame."""
+ config = Config()
+ library = build_mini_library(config)
+ reconstructor = Reconstructor(config, library=library)
+ tone_path = self._tone_path(tmp_path, config)
+
+ reconstruction = reconstructor.reconstruct(
+ [tone_path],
+ StemsConfig.single_entry(list(config.generation.channels), channel_cap=1),
+ )
+
+ assert reconstruction is not None
+ assignments = reconstruction.stems_data.assignments_by_channel
+ frame_count = _frame_count(config, _DURATION_SECONDS)
+
+ for channel, stem_ids in assignments.items():
+ assert set(stem_ids) <= {0, RESTING_STEM_ID}
+ assert len(stem_ids) == frame_count
+ assert len(reconstruction.instructions[channel]) == frame_count
+
+ sounding = [sum(stem_ids[frame] == 0 for stem_ids in assignments.values()) for frame in range(frame_count)]
+ assert sounding == [1] * frame_count
+
+
+class TestStemsCarryTheirOwnSound:
+ """Three recordings sounding one after another, never together.
+
+ Each stem is matched against its own recording, so the frames it holds fall inside the span
+ it sounds in and the channels stand quiet everywhere else. A stem heard on its own therefore
+ plays what was recorded on it.
+ """
+
+ def _recordings(self, config: Config, tmp_path: Path) -> Tuple[Tuple[Path, ...], List[range]]:
+ """Writes one recording per tone, each sounding over its own span of whole frames."""
+ sample_rate = config.library.sample_rate
+ frame_length = config.library.frame_length
+ frames = int(sample_rate * _DISJOINT_DURATION_SECONDS) // frame_length
+ span_frames = frames // len(_DISJOINT_TONES)
+
+ paths: List[Path] = []
+ spans: List[range] = []
+ for index, frequency in enumerate(_DISJOINT_TONES):
+ audio = np.zeros(frames * frame_length, dtype=np.float32)
+ start, stop = index * span_frames, (index + 1) * span_frames
+ samples = slice(start * frame_length, stop * frame_length)
+ time = np.arange(samples.stop - samples.start) / sample_rate
+ audio[samples] = _DISJOINT_AMPLITUDE * np.sin(2 * np.pi * frequency * time)
+ path = tmp_path / f"stem_{index}.wav"
+ write_wave(path, sample_rate, audio)
+ paths.append(path)
+ spans.append(range(start, stop))
+
+ return tuple(paths), spans
+
+ def _stems_config(self, channels: Sequence[ChannelName]) -> StemsConfig:
+ """Every stem may take every channel, each on a level of its own."""
+ return StemsConfig(
+ entries=[StemEntry(id=index, channels=list(channels)) for index in range(len(_DISJOINT_TONES))],
+ hierarchy=StemsHierarchy(
+ levels=[[index] for index in range(len(_DISJOINT_TONES))],
+ mode=HierarchyMode.ROUND_ROBIN,
+ ),
+ channel_cap=len(channels),
+ )
+
+ def _reconstruct(self, tmp_path: Path) -> Tuple[Reconstruction, List[range], Config]:
+ config = Config()
+ library = build_mini_library(config)
+ reconstructor = Reconstructor(config, library=library)
+ paths, spans = self._recordings(config, tmp_path)
+
+ reconstruction = reconstructor.reconstruct(list(paths), self._stems_config(config.generation.channels))
+
+ assert reconstruction is not None
+ return reconstruction, spans, config
+
+ def test_a_stem_holds_frames_only_where_its_recording_sounds(self, tmp_path: Path) -> None:
+ reconstruction, spans, _config = self._reconstruct(tmp_path)
+
+ assignments = reconstruction.stems_data.assignments_by_channel
+ assert assignments
+ for stem_ids in assignments.values():
+ for frame, stem_id in enumerate(stem_ids):
+ if stem_id != RESTING_STEM_ID:
+ assert frame in spans[stem_id]
+
+ def test_every_stem_is_heard_where_its_recording_sounds(self, tmp_path: Path) -> None:
+ """Standing aside where a recording is silent leaves every sounding span answered."""
+ reconstruction, spans, _config = self._reconstruct(tmp_path)
+
+ assignments = reconstruction.stems_data.assignments_by_channel
+ for stem_id, span in enumerate(spans):
+ held = {
+ frame for stem_ids in assignments.values() for frame, holder in enumerate(stem_ids) if holder == stem_id
+ }
+ assert held == set(span)
+
+ def test_soloing_a_stem_sounds_nothing_outside_its_span(self, tmp_path: Path) -> None:
+ """A stem heard on its own falls silent beyond the span its recording sounds in.
+
+ The energy its channels put out there is the leakage, and it is what this measures.
+ """
+ reconstruction, spans, config = self._reconstruct(tmp_path)
+
+ frame_length = config.library.frame_length
+ assignments = reconstruction.stems_data.assignments_by_channel
+ for stem_id, span in enumerate(spans):
+ selection = StemSelection.everywhere(frozenset({stem_id}), list(assignments))
+ heard = ReconstructionData.from_reconstruction(reconstruction, name="disjoint").waveform_data(selection)
+ outside = np.array(heard.approximation, copy=True)
+ for frame in span:
+ outside[frame * frame_length : (frame + 1) * frame_length] = 0.0
+
+ assert float(np.sum(outside**2)) == 0.0
diff --git a/tests/integration/sampletones_application/services/conftest.py b/tests/integration/sampletones_application/services/conftest.py
index 10db4394d..ec6ee4b19 100644
--- a/tests/integration/sampletones_application/services/conftest.py
+++ b/tests/integration/sampletones_application/services/conftest.py
@@ -6,12 +6,13 @@
from sampletones_application.logic.reconstruction.data import ReconstructionData
from sampletones_application.logic.reconstruction.feature import FeatureData
from sampletones_core.configs import Config
-from sampletones_core.constants.enums import GeneratorName
+from sampletones_core.constants.enums import ChannelName
from sampletones_core.constants.general import MIN_PITCH
from sampletones_core.exporters import Features, PulseExporter
from sampletones_core.instructions import PulseInstruction
from sampletones_core.reconstructions import Reconstruction
from tests.suite.application import synchronous_executor, synchronous_queue
+from tests.suite.stems import single_entry_stems_data
__all__ = ["synchronous_executor", "synchronous_queue"]
@@ -45,11 +46,15 @@ def minimal_reconstruction(default_config, pulse_instructions) -> Reconstruction
length = 256
return Reconstruction.create(
approximation=np.zeros(length, dtype=np.float32),
- approximations={GeneratorName.PULSE1: np.zeros(length, dtype=np.float32)},
- instructions={GeneratorName.PULSE1: pulse_instructions},
+ approximations={ChannelName.PULSE1: np.zeros(length, dtype=np.float32)},
+ instructions={ChannelName.PULSE1: pulse_instructions},
config=default_config,
coefficient=1.0,
- audio_filepath=Path("/dev/null"),
+ audio_filepath=(Path("/dev/null"),),
+ stems_data=single_entry_stems_data(
+ list(default_config.generation.channels),
+ {ChannelName.PULSE1: pulse_instructions},
+ ),
)
@@ -59,7 +64,7 @@ def reconstruction_data(default_config, minimal_reconstruction) -> Reconstructio
return ReconstructionData(
config=default_config,
reconstruction=minimal_reconstruction,
- original_audio=np.zeros(256, dtype=np.float32),
+ stem_audios=(),
feature_data=feature_data,
filepath=Path("/dev/null"),
name="null",
diff --git a/tests/integration/sampletones_application/services/test_conversion.py b/tests/integration/sampletones_application/services/test_conversion.py
index 57ecda625..fb03df1b6 100644
--- a/tests/integration/sampletones_application/services/test_conversion.py
+++ b/tests/integration/sampletones_application/services/test_conversion.py
@@ -1,49 +1,69 @@
+from pathlib import Path
+from typing import Any, Dict
from unittest.mock import MagicMock, patch
-from sampletones_application.services.conversion import ConversionService
+from sampletones_application.services.conversion.service import ConversionService
from sampletones_core.configs import Config
+from sampletones_core.reconstructions.converter import DirectoryConversion, GroupConversion
+from sampletones_core.reconstructions.converter.plan.protocol import ConversionPlan
+from sampletones_core.reconstructions.reconstructor.stems.configs.config import StemsConfig
+
+
+def _stems(config: Config) -> StemsConfig:
+ return StemsConfig.single_entry(list(config.generation.channels))
class TestConversionServiceArgumentRouting:
- """Verify that ConversionService.start() routes Config and Path arguments to
- ReconstructionConverter correctly, including deriving is_file from real filesystem state.
+ """The service hands the converter the configuration and the plan it was given, untouched.
- ReconstructionConverter itself is still patched — running a real conversion requires WAV
- files and a process pool. The integration value here is that input_path.is_file() is
- evaluated against a real filesystem path (via tmp_path), not a MagicMock.
+ What a request converts is decided above the service, so the service's whole part is to
+ build a converter around that decision and drive its callbacks. The converter itself stays
+ patched here: running one needs real audio and a process pool.
"""
- def _start_with_captured_kwargs(self, config: Config, path) -> dict:
- with patch("sampletones_application.services.conversion.ReconstructionConverter") as mock_cls:
- mock_cls.return_value = MagicMock()
- ConversionService().start(config, path)
+ def _start_with_captured_kwargs(self, config: Config, plan: ConversionPlan) -> Dict[str, Any]:
+ with patch("sampletones_application.services.conversion.service.ReconstructionConverter") as mock_class:
+ mock_class.return_value = MagicMock()
+ ConversionService().start(config, plan)
- return mock_cls.call_args.kwargs
+ return dict(mock_class.call_args.kwargs)
- def test_start_passes_is_file_true_for_regular_file(self, tmp_path, default_config) -> None:
+ def test_start_passes_config_to_converter(self, tmp_path: Path, default_config: Config) -> None:
real_file = tmp_path / "sample.wav"
real_file.write_bytes(b"")
+ plan = GroupConversion(sources=(real_file,), stems=_stems(default_config))
- call_kwargs = self._start_with_captured_kwargs(default_config, real_file)
- assert call_kwargs["is_file"] is True
-
- def test_start_passes_is_file_false_for_directory(self, tmp_path, default_config) -> None:
- real_dir = tmp_path / "samples"
- real_dir.mkdir()
+ assert self._start_with_captured_kwargs(default_config, plan)["config"] is default_config
- call_kwargs = self._start_with_captured_kwargs(default_config, real_dir)
- assert call_kwargs["is_file"] is False
-
- def test_start_passes_config_to_converter(self, tmp_path, default_config) -> None:
+ def test_start_passes_the_plan_to_converter(self, tmp_path: Path, default_config: Config) -> None:
real_file = tmp_path / "sample.wav"
real_file.write_bytes(b"")
+ plan = GroupConversion(sources=(real_file,), stems=_stems(default_config))
- call_kwargs = self._start_with_captured_kwargs(default_config, real_file)
- assert call_kwargs["config"] is default_config
+ assert self._start_with_captured_kwargs(default_config, plan)["plan"] is plan
- def test_start_passes_input_path_to_converter(self, tmp_path, default_config) -> None:
- real_file = tmp_path / "sample.wav"
- real_file.write_bytes(b"")
+ def test_a_directory_plan_reaches_the_converter_as_it_is(self, tmp_path: Path, default_config: Config) -> None:
+ real_directory = tmp_path / "samples"
+ real_directory.mkdir()
+ plan = DirectoryConversion(directory=real_directory, stems=_stems(default_config))
+
+ assert self._start_with_captured_kwargs(default_config, plan)["plan"] is plan
+
+
+class TestDirectoryPlanReadsTheFilesystem:
+ """A directory plan resolves against the real tree, which is where a batch's jobs come from."""
+
+ def test_every_audio_file_below_the_directory_becomes_a_job(
+ self,
+ tmp_path: Path,
+ default_config: Config,
+ ) -> None:
+ (tmp_path / "nested").mkdir()
+ (tmp_path / "a.wav").write_bytes(b"")
+ (tmp_path / "nested" / "b.wav").write_bytes(b"")
+ (tmp_path / "notes.txt").write_text("not audio")
+
+ jobs = DirectoryConversion(directory=tmp_path, stems=_stems(default_config)).jobs(default_config)
- call_kwargs = self._start_with_captured_kwargs(default_config, real_file)
- assert call_kwargs["input_path"] == real_file
+ assert {job.sources[0].name for job in jobs} == {"a.wav", "b.wav"}
+ assert all(job.output_path.suffix == ".stn" for job in jobs)
diff --git a/tests/integration/sampletones_application/services/test_export.py b/tests/integration/sampletones_application/services/test_export.py
index f0d1939c4..ffd7856a5 100644
--- a/tests/integration/sampletones_application/services/test_export.py
+++ b/tests/integration/sampletones_application/services/test_export.py
@@ -7,13 +7,38 @@
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_application.services.result import ServiceProgress
from sampletones_core.audio import read_wave
-from sampletones_core.constants.enums import GeneratorName
+from sampletones_core.constants.enums import ChannelName
from sampletones_core.exporters import Features
-from sampletones_core.trackers.implementation.famitracker import FamiTrackerBackend
-from sampletones_core.trackers.request import InstrumentExport, SampleExport
+from sampletones_core.exports.implementation.famitracker import FamiTrackerBackend
+from sampletones_core.exports.request import (
+ InstrumentExport,
+ ProjectExport,
+ SampleExport,
+)
+from sampletones_core.exports.stage import ExportStage
+from sampletones_core.project.project import Project
+from sampletones_core.timing import SongTiming
+from sampletones_player.export import NSFBackend
+from sampletones_player.specification.nsf import NSF_MAGIC, PROGRAM_SIZE
+from sampletones_shared.music import Tuning
+from tests.suite.performance import (
+ make_pulse_reconstruction,
+ place_instrument,
+ project_with_sample,
+)
+from tests.suite.player import varied_features
NES_FREQUENCY: Final[int] = 60
+REFERENCE_PITCH: Final[int] = 60
+ROWS_PER_PATTERN: Final[int] = 4
+SOUNDING_TICKS: Final[int] = 8
+
+
+def outcome(results: List[Any]) -> Any:
+ """The result a run finished on, which follows whatever it said while it ran."""
+ return results[-1]
@pytest.fixture(name="backend")
@@ -21,18 +46,33 @@ def backend_fixture() -> FamiTrackerBackend:
return FamiTrackerBackend()
+@pytest.fixture(name="console_backend")
+def console_backend_fixture() -> NSFBackend:
+ return NSFBackend()
+
+
+def overlong_features(initial_pitch: int) -> Features:
+ """Envelopes turning over at every tick for longer than the program area has room for."""
+ return varied_features(PROGRAM_SIZE, initial_pitch, duty_cycle=True)
+
+
def instrument_export(name: str, features: Features) -> InstrumentExport:
return InstrumentExport(
name=name,
- generator=GeneratorName.PULSE1,
+ channel=ChannelName.PULSE1,
features=features,
- loop=False,
nes_frequency=NES_FREQUENCY,
+ tuning=Tuning(),
)
def sample_export(name: str, *instruments: InstrumentExport) -> SampleExport:
- return SampleExport(name=name, instruments=instruments, nes_frequency=NES_FREQUENCY)
+ return SampleExport(
+ name=name,
+ instruments=instruments,
+ nes_frequency=NES_FREQUENCY,
+ tuning=Tuning(),
+ )
class TestExportWavIntegration:
@@ -54,10 +94,9 @@ def test_emits_export_success_with_correct_filepath(self, tmp_path, default_conf
filepath = tmp_path / "output.wav"
export_service.export_wav(filepath, default_config.sample_rate, np.zeros(1000, dtype=np.float32))
- assert len(results) == 1
- assert isinstance(results[0], ExportSuccess)
- assert results[0].kind == ExportKind.WAV
- assert results[0].filepath == filepath
+ assert isinstance(outcome(results), ExportSuccess)
+ assert outcome(results).kind == ExportKind.WAV
+ assert outcome(results).filepath == filepath
def test_written_wav_is_readable(self, tmp_path, default_config) -> None:
export_service = ExportService()
@@ -77,9 +116,8 @@ def test_invalid_sample_rate_emits_export_error(self, tmp_path) -> None:
export_service.export_wav(tmp_path / "output.wav", 1234, np.zeros(100, dtype=np.float32))
- assert len(results) == 1
- assert isinstance(results[0], ExportError)
- assert results[0].kind == ExportKind.WAV
+ assert isinstance(outcome(results), ExportError)
+ assert outcome(results).kind == ExportKind.WAV
class TestExportInstrumentIntegration:
@@ -101,10 +139,9 @@ def test_emits_export_success_with_correct_kind_and_filepath(self, tmp_path, pul
filepath = tmp_path / "instrument.fti"
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
+ assert isinstance(outcome(results), ExportSuccess)
+ assert outcome(results).kind == ExportKind.INSTRUMENT
+ assert outcome(results).filepath == filepath
def test_directory_path_emits_export_error(self, tmp_path, pulse_features, backend) -> None:
export_service = ExportService()
@@ -113,9 +150,8 @@ def test_directory_path_emits_export_error(self, tmp_path, pulse_features, backe
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
+ assert isinstance(outcome(results), ExportError)
+ assert outcome(results).kind == ExportKind.INSTRUMENT
class TestExportSampleIntegration:
@@ -145,11 +181,10 @@ def test_emits_export_success_with_a_path_that_was_written(self, tmp_path, pulse
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.SAMPLE
- assert results[0].filepath == tmp_path / "inst.fti"
- assert results[0].filepath.exists()
+ assert isinstance(outcome(results), ExportSuccess)
+ assert outcome(results).kind == ExportKind.SAMPLE
+ assert outcome(results).filepath == tmp_path / "inst.fti"
+ assert outcome(results).filepath.exists()
def test_new_directory_is_created(self, tmp_path, pulse_features, backend) -> None:
new_dir = tmp_path / "subdir"
@@ -169,4 +204,99 @@ def test_a_sample_with_no_slices_creates_no_files(self, tmp_path, backend) -> No
export_service.export_sample(tmp_path / "sample.fti", backend, sample_export("sample"))
assert list(tmp_path.glob("*.fti")) == []
- assert isinstance(results[0], ExportSuccess)
+ assert isinstance(outcome(results), ExportSuccess)
+
+
+class TestExportToTheConsoleIntegration:
+ """The console player's backend writing through the same service the trackers do."""
+
+ def test_a_playable_program_is_created_on_disk(self, tmp_path, pulse_features, console_backend) -> None:
+ export_service = ExportService()
+ export_service.subscribe(lambda _: None)
+
+ filepath = tmp_path / "sample.nsf"
+ export_service.export_sample(
+ filepath, console_backend, sample_export("sample", instrument_export("inst", pulse_features))
+ )
+
+ assert filepath.read_bytes()[: len(NSF_MAGIC)] == NSF_MAGIC
+
+ def test_the_result_names_the_program_that_was_written(self, tmp_path, pulse_features, console_backend) -> None:
+ export_service = ExportService()
+ results: List[Any] = []
+ export_service.subscribe(results.append)
+
+ filepath = tmp_path / "sample.nsf"
+ export_service.export_sample(
+ filepath, console_backend, sample_export("sample", instrument_export("inst", pulse_features))
+ )
+
+ assert isinstance(outcome(results), ExportSuccess)
+ assert outcome(results).kind == ExportKind.SAMPLE
+ assert outcome(results).filepath == filepath
+
+ def test_a_reconstruction_outgrowing_the_program_area_is_reported(self, tmp_path, console_backend) -> None:
+ """The console holds one program in 32 KB, so a reconstruction running past it reaches
+ the user as a failed export rather than as a file playing part of itself.
+ """
+ export_service = ExportService()
+ results: List[Any] = []
+ export_service.subscribe(results.append)
+
+ filepath = tmp_path / "sample.nsf"
+ request = sample_export("sample", instrument_export("inst", overlong_features(REFERENCE_PITCH)))
+ export_service.export_sample(filepath, console_backend, request)
+
+ assert isinstance(outcome(results), ExportError)
+ assert outcome(results).kind == ExportKind.SAMPLE
+
+
+def arranged_project() -> Project:
+ """A project whose song sounds one sample from its first row."""
+ project, sample = project_with_sample(
+ make_pulse_reconstruction(pitch=REFERENCE_PITCH, count=SOUNDING_TICKS),
+ rows_per_pattern=ROWS_PER_PATTERN,
+ )
+ place_instrument(
+ project,
+ channel_name=ChannelName.PULSE1,
+ row_index=0,
+ sample=sample,
+ )
+ return project
+
+
+class TestExportProjectToTheConsoleIntegration:
+ """A whole arrangement reaching the console through the service the application exports by."""
+
+ def test_the_song_is_written_as_one_program(self, tmp_path, console_backend) -> None:
+ export_service = ExportService()
+ results: List[Any] = []
+ export_service.subscribe(results.append)
+
+ filepath = tmp_path / "song.nsf"
+ export_service.export_project(filepath, console_backend, ProjectExport(project=arranged_project()))
+
+ assert isinstance(outcome(results), ExportSuccess)
+ assert outcome(results).kind == ExportKind.PROJECT
+ assert outcome(results).filepath == filepath
+ assert filepath.read_bytes()[: len(NSF_MAGIC)] == NSF_MAGIC
+
+ def test_the_walk_reads_as_a_fraction_of_the_song(self, tmp_path, console_backend) -> None:
+ """A song states the ticks it lasts before a row of it is played, so the stage that
+ plays it out travels toward a length the dialog can draw."""
+ project = arranged_project()
+ export_service = ExportService()
+ results: List[Any] = []
+ export_service.subscribe(results.append)
+
+ export_service.export_project(tmp_path / "song.nsf", console_backend, ProjectExport(project=project))
+
+ walked = [
+ result
+ for result in results
+ if isinstance(result, ServiceProgress) and result.current_item == ExportStage.WALKING
+ ]
+ groove = SongTiming.from_project(project).groove()
+ assert walked
+ assert walked[-1].total == project.song.order_length() * groove.total_ticks
diff --git a/tests/integration/sampletones_application/services/test_regeneration.py b/tests/integration/sampletones_application/services/test_regeneration.py
index eaa09995e..fcfefed8d 100644
--- a/tests/integration/sampletones_application/services/test_regeneration.py
+++ b/tests/integration/sampletones_application/services/test_regeneration.py
@@ -6,11 +6,12 @@
import pytest
from sampletones_application.logic.reconstruction.feature import FeatureData
-from sampletones_application.services.regeneration import RegenerationService
+from sampletones_application.services.regeneration.service 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.constants.enums import ChannelName, FeatureKey
from sampletones_core.exporters import Features
+from sampletones_core.features.envelope import Envelope
from sampletones_core.reconstructions import Reconstruction
from tests.suite.scenario import BaseTestScenario, ScenarioStep
@@ -29,7 +30,7 @@
class TestRegenerationServicePipeline:
"""Full synthesis pipeline: real Config, Features (via PulseExporter), real PulseGenerator,
- and real Reconstruction.update_generator_data. Nothing is mocked.
+ and real Reconstruction.update_channel_data. Nothing is mocked.
Tests call _run() directly to bypass the executor; the synchronous_executor fixture
from the parent conftest covers start() in the final test.
@@ -42,10 +43,9 @@ def test_run_emits_service_success(self, reconstruction_data, pulse_features) ->
service._run(
reconstruction_data.reconstruction,
- GeneratorName.PULSE1,
- pulse_features,
+ ChannelName.PULSE1,
FeatureKey.VOLUME,
- pulse_features.volume,
+ pulse_features,
)
assert len(results) == 1
@@ -58,16 +58,15 @@ def test_run_emits_new_reconstruction_carrying_the_edit(self, reconstruction_dat
service._run(
reconstruction_data.reconstruction,
- GeneratorName.PULSE1,
- pulse_features,
+ ChannelName.PULSE1,
FeatureKey.VOLUME,
- pulse_features.volume,
+ pulse_features,
)
emitted = results[0].value
assert emitted.reconstruction is not reconstruction_data.reconstruction
- assert len(emitted.reconstruction.approximations.get(GeneratorName.PULSE1, np.array([], dtype=np.float32))) > 0
- assert emitted.generator_name is GeneratorName.PULSE1
+ assert len(emitted.reconstruction.approximations.get(ChannelName.PULSE1, np.array([], dtype=np.float32))) > 0
+ assert emitted.channel_name is ChannelName.PULSE1
assert emitted.feature_key is FeatureKey.VOLUME
def test_run_updates_reconstruction_approximation(self, reconstruction_data, pulse_features) -> None:
@@ -75,14 +74,13 @@ def test_run_updates_reconstruction_approximation(self, reconstruction_data, pul
service._run(
reconstruction_data.reconstruction,
- GeneratorName.PULSE1,
- pulse_features,
+ ChannelName.PULSE1,
FeatureKey.VOLUME,
- pulse_features.volume,
+ pulse_features,
)
approximation = reconstruction_data.reconstruction.approximations.get(
- GeneratorName.PULSE1, np.array([], dtype=np.float32)
+ ChannelName.PULSE1, np.array([], dtype=np.float32)
)
assert len(approximation) > 0
@@ -91,28 +89,30 @@ def test_run_updates_reconstruction_instructions(self, reconstruction_data, puls
service._run(
reconstruction_data.reconstruction,
- GeneratorName.PULSE1,
- pulse_features,
+ ChannelName.PULSE1,
FeatureKey.VOLUME,
- pulse_features.volume,
+ pulse_features,
)
- instructions = reconstruction_data.reconstruction.get_generator_instructions(GeneratorName.PULSE1)
+ instructions = reconstruction_data.reconstruction.get_channel_instructions(ChannelName.PULSE1)
assert len(instructions) > 0
- def test_run_feature_mutation_is_applied_before_synthesis(self, reconstruction_data, pulse_features) -> None:
- new_volume = np.zeros(len(pulse_features.volume), dtype=np.int8)
- service = RegenerationService()
-
- service._run(
- reconstruction_data.reconstruction,
- GeneratorName.PULSE1,
- pulse_features,
+ def test_run_regenerates_from_the_envelopes_it_is_handed(self, reconstruction_data, pulse_features) -> None:
+ """The caller writes the edit into the envelopes, so the service renders what it is given."""
+ silenced = pulse_features.with_envelope(
FeatureKey.VOLUME,
- new_volume,
+ Envelope(items=(0,) * len(pulse_features.volume.items)),
)
+ service = RegenerationService()
+ results: List[Any] = []
+ service.subscribe(results.append)
+
+ service._run(reconstruction_data.reconstruction, ChannelName.PULSE1, FeatureKey.VOLUME, silenced)
- assert (pulse_features.volume == new_volume).all()
+ assert isinstance(results[0], ServiceSuccess)
+ assert all(
+ not instruction.on for instruction in results[0].value.reconstruction.instructions[ChannelName.PULSE1]
+ )
def test_run_emits_service_error_for_wrong_features_type(self, reconstruction_data) -> None:
service = RegenerationService()
@@ -121,10 +121,9 @@ def test_run_emits_service_error_for_wrong_features_type(self, reconstruction_da
service._run(
reconstruction_data.reconstruction,
- GeneratorName.PULSE1,
- {},
+ ChannelName.PULSE1,
FeatureKey.VOLUME,
- np.zeros(4, dtype=np.int8),
+ {},
)
assert len(results) == 1
@@ -137,10 +136,9 @@ def test_start_completes_through_full_pipeline(self, reconstruction_data, pulse_
service.start(
reconstruction_data.reconstruction,
- GeneratorName.PULSE1,
- pulse_features,
+ ChannelName.PULSE1,
FeatureKey.VOLUME,
- pulse_features.volume,
+ pulse_features,
)
assert len(results) == 1
@@ -164,12 +162,15 @@ def _edit_arpeggio(context: ArpeggioEditContext, arpeggio: np.ndarray) -> None:
results: List[Any] = []
service.subscribe(results.append)
+ edited = context.features.with_envelope(
+ FeatureKey.ARPEGGIO,
+ Envelope(items=tuple(int(offset) for offset in arpeggio)),
+ )
service._run(
context.reconstruction,
- GeneratorName.PULSE1,
- context.features,
+ ChannelName.PULSE1,
FeatureKey.ARPEGGIO,
- arpeggio,
+ edited,
)
assert len(results) == 1
@@ -179,7 +180,7 @@ def _edit_arpeggio(context: ArpeggioEditContext, arpeggio: np.ndarray) -> None:
def _pitches(context: ArpeggioEditContext) -> List[int]:
- instructions = context.reconstruction.get_generator_instructions(GeneratorName.PULSE1)
+ instructions = context.reconstruction.get_channel_instructions(ChannelName.PULSE1)
return [instruction.pitch for instruction in instructions]
@@ -196,30 +197,30 @@ def build() -> ArpeggioEditContext:
reconstruction = reconstruction_data.reconstruction
return ArpeggioEditContext(
reconstruction=reconstruction,
- features=FeatureData.load(reconstruction)[GeneratorName.PULSE1],
+ features=FeatureData.load(reconstruction)[ChannelName.PULSE1],
)
def check_the_starting_reference(context: ArpeggioEditContext) -> None:
assert context.features.initial_pitch == BASE_PITCH
- assert context.features.arpeggio.tolist() == [0]
+ assert list(context.features.arpeggio.items) == [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]
+ context.features = FeatureData.load(context.reconstruction)[ChannelName.PULSE1]
assert context.features.initial_pitch == BASE_PITCH
- assert context.features.arpeggio.tolist() == [OCTAVE, 0]
+ assert list(context.features.arpeggio.items) == [OCTAVE, 0]
def clear_the_envelope(context: ArpeggioEditContext) -> None:
- _edit_arpeggio(context, np.zeros(len(context.features.arpeggio), dtype=np.int8))
+ _edit_arpeggio(context, np.zeros(len(context.features.arpeggio.items), dtype=np.int8))
assert _pitches(context) == [BASE_PITCH] * 4
def check_the_reference_held(context: ArpeggioEditContext) -> None:
- reloaded = FeatureData.load(context.reconstruction)[GeneratorName.PULSE1]
+ reloaded = FeatureData.load(context.reconstruction)[ChannelName.PULSE1]
assert reloaded.initial_pitch == BASE_PITCH
- assert reloaded.arpeggio.tolist() == [0]
+ assert list(reloaded.arpeggio.items) == [0]
scenario = BaseTestScenario(
label="arpeggio_edit_keeps_the_sample_pitch",
@@ -287,10 +288,9 @@ def test_result_delivered_despite_future_lower_priority_task(self, reconstructio
# The real synthesis emits its ServiceSuccess onto the real queue, due at the current frame.
service._run(
reconstruction_data.reconstruction,
- GeneratorName.PULSE1,
- pulse_features,
+ ChannelName.PULSE1,
FeatureKey.VOLUME,
- pulse_features.volume,
+ pulse_features,
)
# The real queue defers delivery until a frame is pumped; nothing has run yet. This also fails
@@ -315,13 +315,12 @@ def test_edit_reaches_subscriber_within_frame_budget(self, reconstruction_data,
CallbackQueue.add(lambda: None, priority=SETTLE_PRIORITY, delay=SETTLE_DELAY_FRAMES)
- new_volume = np.zeros(len(pulse_features.volume), dtype=np.int8)
+ new_volume = np.zeros(len(pulse_features.volume.items), dtype=np.int8)
service._run(
reconstruction_data.reconstruction,
- GeneratorName.PULSE1,
- pulse_features,
+ ChannelName.PULSE1,
FeatureKey.VOLUME,
- new_volume,
+ pulse_features,
)
for _ in range(DELIVERY_BUDGET_FRAMES):
@@ -333,5 +332,5 @@ def test_edit_reaches_subscriber_within_frame_budget(self, reconstruction_data,
assert len(delivered) == 1
regenerated = delivered[0].value
assert regenerated.reconstruction is not reconstruction_data.reconstruction
- assert regenerated.generator_name is GeneratorName.PULSE1
+ assert regenerated.channel_name is ChannelName.PULSE1
assert regenerated.feature_key is FeatureKey.VOLUME
diff --git a/tests/integration/sampletones_core/__init__.py b/tests/integration/sampletones_core/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/tests/integration/sampletones_core/parallelization/__init__.py b/tests/integration/sampletones_core/parallelization/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/tests/integration/sampletones_core/parallelization/test_progress_channel.py b/tests/integration/sampletones_core/parallelization/test_progress_channel.py
new file mode 100644
index 000000000..4fae8b94b
--- /dev/null
+++ b/tests/integration/sampletones_core/parallelization/test_progress_channel.py
@@ -0,0 +1,140 @@
+from contextlib import contextmanager
+from pathlib import Path
+from typing import Final, Iterator, Tuple
+
+import pytest
+
+from sampletones_core.parallelization.task import TaskStatus
+from tests.suite.parallelization import (
+ COUNTING_STAGE,
+ STEP_COUNT,
+ CountingProcessor,
+ ProgressRecorder,
+ several_tasks_reporting,
+ stands_partway,
+)
+
+READING_TIMEOUT: Final[float] = 60.0
+POOL_TIMEOUT: Final[float] = 120.0
+ONE_TASK: Final[int] = 1
+SEVERAL_TASKS: Final[int] = 3
+LONE_WORKER: Final[int] = 1
+TWO_WORKERS: Final[int] = 2
+FIRST_COUNT: Final[int] = 1
+WHOLE_RUN: Final[float] = 1.0
+RELEASE_NAME: Final[str] = "release"
+
+
+@pytest.fixture
+def release_path(tmp_path: Path) -> Path:
+ """Where a test puts the file its tasks wait on before they finish counting."""
+ return tmp_path / RELEASE_NAME
+
+
+@contextmanager
+def counting_run(
+ task_count: int,
+ release_path: Path,
+ workers: int,
+) -> Iterator[Tuple[CountingProcessor, ProgressRecorder]]:
+ """Starts a counting run, hands the test its recorder, and reaps the pool afterwards.
+
+ The release file is written on the way out whatever the test did, so a run whose assertion
+ failed before releasing its tasks still ends rather than holding a worker at its halfway mark.
+ """
+ recorder = ProgressRecorder()
+ processor = CountingProcessor(task_count, release_path, max_workers=workers)
+ processor.set_callbacks(on_progress=recorder)
+ processor.start()
+ try:
+ yield processor, recorder
+ finally:
+ release_path.touch(exist_ok=True)
+ processor.shutdown()
+
+
+class TestOneTaskReportsItselfBeforeItFinishes:
+ """A run made of a single task says how far that task has come while it is still running.
+
+ Every conversion is one job whatever the number of stems, so a run reporting completions alone
+ stands at nothing for its whole length and then jumps to full. The task here is held at its
+ halfway mark until the reading below has already been taken, so what each assertion rests on is
+ a report that crossed from a live worker process rather than from a finished one.
+ """
+
+ def test_a_reading_arrives_from_a_task_still_running(self, release_path: Path) -> None:
+ with counting_run(ONE_TASK, release_path, LONE_WORKER) as (processor, recorder):
+ assert recorder.wait_for(stands_partway, READING_TIMEOUT)
+
+ release_path.touch()
+ processor.wait(POOL_TIMEOUT)
+
+ def test_the_steps_carry_what_the_task_said_of_itself(self, release_path: Path) -> None:
+ with counting_run(ONE_TASK, release_path, LONE_WORKER) as (processor, recorder):
+ assert recorder.wait_for(stands_partway, READING_TIMEOUT)
+ release_path.touch()
+ processor.wait(POOL_TIMEOUT)
+
+ steps = recorder.steps
+ assert steps
+ assert {step.stage for step in steps} == {COUNTING_STAGE}
+ assert {step.total for step in steps} == {STEP_COUNT}
+ assert all(FIRST_COUNT <= step.completed <= STEP_COUNT for step in steps)
+ assert all(step.fraction == step.completed / STEP_COUNT for step in steps)
+
+ def test_the_run_climbs_and_arrives_at_its_end(self, release_path: Path) -> None:
+ with counting_run(ONE_TASK, release_path, LONE_WORKER) as (processor, recorder):
+ assert recorder.wait_for(stands_partway, READING_TIMEOUT)
+ release_path.touch()
+ processor.wait(POOL_TIMEOUT)
+
+ fractions = recorder.fractions
+ assert fractions == sorted(fractions)
+ assert fractions[-1] == WHOLE_RUN
+ assert recorder.last_of(TaskStatus.COMPLETED) is not None
+
+
+class TestSeveralTasksReportSideBySide:
+ """Tasks running at once each report on a line of its own, and the run reads them together.
+
+ A finished task's share is let go of as the run counts it, and a report that was already on its
+ way is history by then, so the two ways of describing the same work never add up twice — which
+ is what keeps the reading inside the run it describes.
+ """
+
+ def test_two_tasks_stand_on_their_own_lines_at_once(self, release_path: Path) -> None:
+ with counting_run(SEVERAL_TASKS, release_path, TWO_WORKERS) as (processor, recorder):
+ assert recorder.wait_for(several_tasks_reporting, READING_TIMEOUT)
+
+ release_path.touch()
+ processor.wait(POOL_TIMEOUT)
+
+ def test_the_reading_climbs_and_stays_within_the_run(self, release_path: Path) -> None:
+ with counting_run(SEVERAL_TASKS, release_path, TWO_WORKERS) as (processor, recorder):
+ assert recorder.wait_for(several_tasks_reporting, READING_TIMEOUT)
+ release_path.touch()
+ processor.wait(POOL_TIMEOUT)
+
+ fractions = recorder.fractions
+ assert fractions == sorted(fractions)
+ assert max(fractions) == WHOLE_RUN
+
+
+class TestAWithdrawalReachesTheTasks:
+ """A run let go of unwinds the tasks it handed out, over the same channel they report on.
+
+ The tasks are held at their halfway mark, so the withdrawal below is delivered to workers that
+ are provably still running: what ends the run is the answer the reporter gave them, and the
+ pool being torn down afterwards is the backstop rather than the mechanism.
+ """
+
+ def test_a_withdrawn_run_ends_canceled(self, release_path: Path) -> None:
+ with counting_run(SEVERAL_TASKS, release_path, TWO_WORKERS) as (processor, recorder):
+ assert recorder.wait_for(stands_partway, READING_TIMEOUT)
+
+ processor.cancel()
+ release_path.touch()
+ processor.wait(POOL_TIMEOUT)
+
+ assert recorder.last_of(TaskStatus.CANCELLING) is not None
+ assert not processor.is_running()
diff --git a/tests/suite/application.py b/tests/suite/application.py
index e8df8a13a..6e8a51579 100644
--- a/tests/suite/application.py
+++ b/tests/suite/application.py
@@ -5,8 +5,12 @@
from sampletones_application.layout.behavior.scheduling.delays import SchedulingDelays
from sampletones_application.layout.behavior.scheduling.emit import SchedulingEmit
-from sampletones_application.layout.behavior.scheduling.priorities import SchedulingPriorities
-from sampletones_application.layout.behavior.scheduling.scheduling import SchedulingBehavior
+from sampletones_application.layout.behavior.scheduling.priorities import (
+ SchedulingPriorities,
+)
+from sampletones_application.layout.behavior.scheduling.scheduling import (
+ SchedulingBehavior,
+)
from sampletones_application.utils.callbacks.queue import CallbackQueue
from sampletones_application.utils.parallelization.thread import SingleThreadExecutor
from sampletones_shared.types.callback import VoidCallback
@@ -46,7 +50,7 @@ def execute_sync(self: SingleThreadExecutor, target: VoidCallback, wait: bool =
@pytest.fixture
def scheduling() -> SchedulingBehavior:
- """Scheduling behaviour with every delay collapsed to zero for deterministic tests."""
+ """Scheduling behavior with every delay collapsed to zero for deterministic tests."""
return SchedulingBehavior(
delays=SchedulingDelays(
schedule=0,
diff --git a/tests/suite/browser.py b/tests/suite/browser.py
index 45eff6f26..8263f4fe2 100644
--- a/tests/suite/browser.py
+++ b/tests/suite/browser.py
@@ -218,7 +218,7 @@ def _rows_nested_under(rows: Sequence[_Row], index: int) -> Set[int]:
def _assert_rows_named(rows: Sequence[_Row], labels: AbstractSet[str]) -> None:
"""Holds a derived view to the rows the view it varies reads, so a label naming none is stated."""
missing = labels - {row.label for row in rows}
- assert not missing, f"the view holds no row labelled {sorted(missing)}"
+ assert not missing, f"the view holds no row labeled {sorted(missing)}"
def config_fields(
@@ -227,7 +227,7 @@ def config_fields(
nes_frequency: int,
spectrum_method: SpectrumMethod,
transformation_gamma: int,
- generators: str,
+ channels: str,
config_hash: str,
) -> ConfigDirectoryFields:
return ConfigDirectoryFields(
@@ -235,7 +235,7 @@ def config_fields(
nf=nes_frequency,
sm=spectrum_method,
tg=transformation_gamma,
- gn=generators,
+ gn=channels,
ch=config_hash,
)
@@ -245,7 +245,7 @@ def config_fields(
nes_frequency=30,
spectrum_method=SpectrumMethod.FFT,
transformation_gamma=0,
- generators="PTN",
+ channels="PTN",
config_hash=HASH_A,
)
CONFIG_B: Final[ConfigDirectoryFields] = config_fields(
@@ -253,7 +253,7 @@ def config_fields(
nes_frequency=30,
spectrum_method=SpectrumMethod.FFT,
transformation_gamma=0,
- generators="PTN",
+ channels="PTN",
config_hash=HASH_B,
)
CONFIG_C: Final[ConfigDirectoryFields] = config_fields(
@@ -261,7 +261,7 @@ def config_fields(
nes_frequency=30,
spectrum_method=SpectrumMethod.FFT,
transformation_gamma=0,
- generators="PT",
+ channels="PT",
config_hash=HASH_C,
)
CONFIG_D: Final[ConfigDirectoryFields] = config_fields(
@@ -269,7 +269,7 @@ def config_fields(
nes_frequency=30,
spectrum_method=SpectrumMethod.CQT,
transformation_gamma=0,
- generators="PTN",
+ channels="PTN",
config_hash=HASH_D,
)
CONFIG_E: Final[ConfigDirectoryFields] = config_fields(
@@ -277,7 +277,7 @@ def config_fields(
nes_frequency=60,
spectrum_method=SpectrumMethod.CQT,
transformation_gamma=2,
- generators="P",
+ channels="P",
config_hash=HASH_E,
)
CONFIG_F: Final[ConfigDirectoryFields] = config_fields(
@@ -285,7 +285,7 @@ def config_fields(
nes_frequency=50,
spectrum_method=SpectrumMethod.LOG_SPACED_FFT,
transformation_gamma=1,
- generators="TN",
+ channels="TN",
config_hash=HASH_F,
)
@@ -448,7 +448,7 @@ def _state_detail_labels(panel: GUITreePanel) -> None:
panel._lbl_detail_spectrum_method = "spectrum_method"
panel._lbl_detail_transformation_gamma = "transformation_gamma"
panel._lbl_detail_window_size = "window_size"
- panel._lbl_detail_generators = "generators"
+ panel._lbl_detail_channels = "channels"
panel._lbl_detail_configuration = "configuration"
diff --git a/tests/suite/conversion.py b/tests/suite/conversion.py
new file mode 100644
index 000000000..0c29d667e
--- /dev/null
+++ b/tests/suite/conversion.py
@@ -0,0 +1,75 @@
+from pathlib import Path
+from typing import Final, FrozenSet, Sequence
+
+from sampletones_core.configs import Config
+from sampletones_core.reconstructions.progress import (
+ STAGE_BEGUN,
+ WHOLE_STAGE,
+ ReconstructionReporter,
+ announce,
+)
+from sampletones_core.reconstructions.reconstructor.stems.configs.config import StemsConfig
+from sampletones_core.reconstructions.stage import ReconstructionStage
+from sampletones_shared.types.path import Pathlike
+from tests.suite.release import wait_for_release
+
+FAKE_FRAMES: Final[int] = 40
+HALFWAY: Final[int] = FAKE_FRAMES // 2
+COUNTED_STAGES: Final[FrozenSet[ReconstructionStage]] = frozenset(
+ {ReconstructionStage.MATCHING, ReconstructionStage.RENDERING}
+)
+
+
+class FakeReconstructor:
+ """A reconstructor that walks the stages a real one does and builds nothing.
+
+ Reconstructing needs an instruction library and seconds of arithmetic per frame, none of which
+ the wiring under test depends on: what a run hands its jobs, what those jobs report, and how
+ the run reads it back. Walking the stages stands in for the work, and the run is left with no
+ file to write, which is what a job whose reconstruction came to nothing already does.
+
+ Travels to a worker with the job, the way the real one does, so it is a plain picklable object
+ built from a configuration the same way — which is what lets a run construct one in its place.
+ The walk pauses halfway through matching until the test releases it, so an assertion about a
+ reconstruction under way is made while that reconstruction is provably under way.
+ """
+
+ def __init__(self, config: Config, release_path: Path, *, frames: int = FAKE_FRAMES) -> None:
+ self.config = config
+ self.release_path = release_path
+ self.frames = frames
+
+ def reconstruct(
+ self,
+ paths: Sequence[Pathlike],
+ stems_config: StemsConfig,
+ *,
+ report: ReconstructionReporter,
+ ) -> None:
+ """Walks the stages of a reconstruction, reporting each, and answers with nothing built.
+
+ The walk follows the stages in the order they are declared, so it passes through them the
+ way a reconstruction does however that order comes to change.
+
+ Raises:
+ OperationCanceled: If the walk is withdrawn while it is under way.
+ """
+ for stage in ReconstructionStage:
+ if stage not in COUNTED_STAGES:
+ announce(report, stage, STAGE_BEGUN, WHOLE_STAGE)
+ continue
+
+ self._walk(report, stage)
+
+ def _walk(self, report: ReconstructionReporter, stage: ReconstructionStage) -> None:
+ for frame in range(self.frames + 1):
+ announce(report, stage, frame, self.frames)
+ if stage == ReconstructionStage.MATCHING and frame == HALFWAY:
+ wait_for_release(self.release_path)
+
+
+def write_silent_recording(path: Path) -> Path:
+ """Leaves a file where a recording would be, since the walk above reads none."""
+ path.parent.mkdir(parents=True, exist_ok=True)
+ path.touch()
+ return path
diff --git a/tests/suite/language.py b/tests/suite/language.py
index 5e298af7e..0cbac9c6b 100644
--- a/tests/suite/language.py
+++ b/tests/suite/language.py
@@ -8,7 +8,7 @@ class FakeLanguageManager:
A test asserting on the text a widget or a callback receives then names the entry the code
read, which holds the wiring in place while leaving the wording to the language file. Where
- the behaviour under test formats the text — a template with placeholders — the test states
+ the behavior under test formats the text — a template with placeholders — the test states
that text explicitly.
"""
diff --git a/tests/suite/parallelization.py b/tests/suite/parallelization.py
new file mode 100644
index 000000000..4d451522c
--- /dev/null
+++ b/tests/suite/parallelization.py
@@ -0,0 +1,153 @@
+import threading
+from dataclasses import dataclass
+from pathlib import Path
+from typing import Any, Callable, Final, FrozenSet, List, Optional, Tuple
+
+from sampletones_core.parallelization.channel.protocol import StepReporter
+from sampletones_core.parallelization.processor import TaskProcessor
+from sampletones_core.parallelization.task import TaskProgress, TaskStatus, TaskStep
+from sampletones_shared.exceptions import OperationCanceled
+from tests.suite.release import wait_for_release
+
+COUNTING_STAGE: Final[str] = "counting"
+STEP_COUNT: Final[int] = 8
+HALFWAY: Final[int] = STEP_COUNT // 2
+NOTHING_COMPLETED: Final[int] = 0
+WHOLE_TASK: Final[float] = 1.0
+ONE_STEP: Final[int] = 1
+
+LIVE_STATUSES: Final[FrozenSet[TaskStatus]] = frozenset({TaskStatus.RUNNING, TaskStatus.COMPLETED})
+
+
+@dataclass(frozen=True)
+class CountingTask:
+ """One task of a run that counts, reports each count, and does nothing else.
+
+ A reconstruction needs an audio file, an instruction library and seconds of arithmetic before
+ it can say anything about itself, none of which the channel carrying what it says depends on.
+ Counting stands in for the work, so what a test of the channel measures is the channel.
+
+ The task waits at the halfway mark until the file at ``release_path`` appears, which is how a
+ test can assert that a partial report arrived while the task was provably still running.
+ """
+
+ index: int
+ report: StepReporter
+ release_path: Path
+
+
+def count_task(task: CountingTask) -> int:
+ """Counts to ``STEP_COUNT``, reporting each count, and answers which task did the counting.
+
+ Runs in a pool worker, so the line it reports on traveled here with it.
+
+ Raises:
+ OperationCanceled: If the run is withdrawn while the counting is under way.
+ """
+ for completed in range(1, STEP_COUNT + 1):
+ step = TaskStep(
+ stage=COUNTING_STAGE,
+ completed=completed,
+ total=STEP_COUNT,
+ fraction=completed / STEP_COUNT,
+ )
+ if not task.report(step):
+ raise OperationCanceled(f"task {task.index} was withdrawn at {completed}")
+
+ if completed == HALFWAY:
+ wait_for_release(task.release_path)
+
+ return task.index
+
+
+class CountingProcessor(TaskProcessor[int]):
+ """A run whose tasks only count, so what a test reads is the channel they count over."""
+
+ def __init__(self, task_count: int, release_path: Path, max_workers: int) -> None:
+ super().__init__(max_workers=max_workers)
+ self._task_count = task_count
+ self._release_path = release_path
+
+ def _create_tasks(self) -> List[Any]:
+ return [
+ CountingTask(
+ index=index,
+ report=self._task_reporter(index),
+ release_path=self._release_path,
+ )
+ for index in range(self._task_count)
+ ]
+
+ def _get_task_function(self) -> Callable[[CountingTask], int]:
+ return count_task
+
+ def _process_results(self, results: List[int]) -> Tuple[int, ...]:
+ return tuple(results)
+
+
+class ProgressRecorder:
+ """Keeps every reading a run offers, and waits for the one a test is after.
+
+ A run reports from two threads — the one waiting on results and the one reading the channel —
+ so the readings are gathered under a condition every arrival wakes. A test names the reading it
+ wants and waits for it, which is how an assertion about work in flight is made while that work
+ is provably still in flight.
+ """
+
+ def __init__(self) -> None:
+ self.readings: List[Tuple[TaskStatus, TaskProgress]] = []
+ self._condition = threading.Condition()
+
+ def __call__(self, status: TaskStatus, progress: TaskProgress) -> None:
+ with self._condition:
+ self.readings.append((status, progress))
+ self._condition.notify_all()
+
+ def wait_for(self, matches: Callable[[TaskProgress], bool], timeout: float) -> bool:
+ """Waits for a reading that matches, and answers whether the run offered one.
+
+ Args:
+ matches: What the test is waiting to see the run stand at.
+ timeout: How long to wait for it.
+
+ Returns:
+ bool: Whether such a reading arrived within the time given.
+ """
+ with self._condition:
+ return self._condition.wait_for(
+ lambda: any(matches(progress) for _, progress in self.readings),
+ timeout=timeout,
+ )
+
+ @property
+ def fractions(self) -> List[float]:
+ """How full the run stood at each reading taken while it ran, in the order they arrived.
+
+ A run that has ended lets go of its counts, so the readings its teardown offers describe a
+ processor standing ready rather than the run that just finished.
+ """
+ with self._condition:
+ return [progress.fraction for status, progress in self.readings if status in LIVE_STATUSES]
+
+ @property
+ def steps(self) -> List[TaskStep]:
+ """Every step a task filed, as the readings carried them."""
+ with self._condition:
+ return [step for _, progress in self.readings for step in progress.steps]
+
+ def last_of(self, status: TaskStatus) -> Optional[TaskProgress]:
+ """The final reading the run offered while it stood at ``status``."""
+ with self._condition:
+ matching = [progress for reading, progress in self.readings if reading == status]
+
+ return matching[-1] if matching else None
+
+
+def stands_partway(progress: TaskProgress) -> bool:
+ """A run standing between its ends with no task yet finished, so the work is inside one."""
+ return progress.completed == NOTHING_COMPLETED and 0.0 < progress.fraction < WHOLE_TASK
+
+
+def several_tasks_reporting(progress: TaskProgress) -> bool:
+ """More than one task standing on a line of its own at the same moment."""
+ return len(progress.steps) > ONE_STEP
diff --git a/tests/suite/performance.py b/tests/suite/performance.py
new file mode 100644
index 000000000..78bf607cb
--- /dev/null
+++ b/tests/suite/performance.py
@@ -0,0 +1,159 @@
+from pathlib import Path
+from typing import Dict, Iterable, List, Optional, Tuple
+
+import numpy as np
+
+from sampletones_core.configs import Config
+from sampletones_core.constants.enums import ChannelName, FeatureKey
+from sampletones_core.instructions import (
+ InstructionUnion,
+ NoiseInstruction,
+ PulseInstruction,
+ TriangleInstruction,
+)
+from sampletones_core.project.patterns.row import Row
+from sampletones_core.project.project import Project
+from sampletones_core.project.settings import ProjectSettings
+from sampletones_core.project.voices.instrument import Instrument
+from sampletones_core.project.voices.note_on import NoteOn
+from sampletones_core.project.voices.sample import Sample
+from sampletones_core.project.voices.voice import VoiceUnion
+from sampletones_core.reconstructions import Reconstruction
+from tests.suite.stems import single_entry_stems_data
+
+APPROXIMATION_LENGTH: int = 64
+
+
+def _reconstruction(
+ channel_name: ChannelName,
+ instructions: List[InstructionUnion],
+) -> Reconstruction:
+ approximation = np.zeros(APPROXIMATION_LENGTH, dtype=np.float32)
+ channel_instructions: Dict[ChannelName, List[InstructionUnion]] = {channel_name: instructions}
+ return Reconstruction.create(
+ approximation=approximation,
+ approximations={channel_name: approximation},
+ instructions=channel_instructions,
+ config=Config(),
+ coefficient=1.0,
+ audio_filepath=(Path("/dev/null"),),
+ stems_data=single_entry_stems_data(
+ list(Config().generation.channels),
+ channel_instructions,
+ ),
+ )
+
+
+def make_pulse_reconstruction(
+ *,
+ pitch: int = 60,
+ volume: int = 15,
+ count: int = 1,
+ held_features: Iterable[FeatureKey] = (),
+) -> Reconstruction:
+ """Single-channel reconstruction with ``count`` identical PulseInstructions.
+
+ ``held_features`` names the dimensions the instrument leaves to the channel, which is what
+ an envelope cleared in the instruments panel produces.
+ """
+ instructions: List[InstructionUnion] = [PulseInstruction(on=True, pitch=pitch, volume=volume, duty_cycle=0)] * count
+ reconstruction = _reconstruction(ChannelName.PULSE1, instructions)
+ if held_features:
+ reconstruction.update_channel_data(
+ ChannelName.PULSE1,
+ list(instructions),
+ np.zeros(APPROXIMATION_LENGTH, dtype=np.float32),
+ reconstruction.initial_pitches[ChannelName.PULSE1],
+ held_features,
+ )
+
+ return reconstruction
+
+
+def make_triangle_reconstruction(
+ *,
+ pitch: int = 60,
+ count: int = 1,
+) -> Reconstruction:
+ """Single-channel reconstruction with ``count`` identical TriangleInstructions."""
+ instructions: List[InstructionUnion] = [TriangleInstruction(on=True, pitch=pitch)] * count
+ return _reconstruction(ChannelName.TRIANGLE, instructions)
+
+
+def make_noise_reconstruction(
+ *,
+ period: int = 3,
+ volume: int = 15,
+ count: int = 1,
+) -> Reconstruction:
+ """Single-channel reconstruction with ``count`` identical NoiseInstructions."""
+ instructions: List[InstructionUnion] = [
+ NoiseInstruction(on=True, period=period, volume=volume, short=False)
+ ] * count
+ return _reconstruction(ChannelName.NOISE, instructions)
+
+
+def retuned_reconstruction(
+ reconstruction: Reconstruction,
+ a4_frequency: float,
+) -> Reconstruction:
+ """The same reconstruction read as though concert pitch had sat at ``a4_frequency``.
+
+ A tuning reaches a reconstruction through the library settings it was built with, so a case
+ needing two samples that disagree copies one of them onto another reference.
+ """
+ library = reconstruction.config.library.model_copy(update={"a4_frequency": a4_frequency})
+ return reconstruction.model_copy(update={"config": reconstruction.config.model_copy(update={"library": library})})
+
+
+def project_with_sample(
+ reconstruction: Reconstruction,
+ *,
+ rows_per_pattern: int,
+ settings: Optional[ProjectSettings] = None,
+ loop: bool = False,
+ name: str = "test",
+) -> Tuple[Project, Sample]:
+ """A one-sample project, built through the core models a document is made of.
+
+ Answering with the sample beside the project is what lets a case place it on a row without
+ reaching back into the collection for an id it already knows.
+ """
+ project = Project.create(rows_per_pattern=rows_per_pattern, settings=settings)
+ sample = Sample(name=name, reconstruction=reconstruction)
+ project.voices.append(sample)
+ return project, sample
+
+
+def project_with_instrument(
+ instrument: Instrument,
+ *,
+ rows_per_pattern: int,
+ settings: Optional[ProjectSettings] = None,
+) -> Project:
+ """A one-instrument project, so a case can place a hand-written voice on any channel it likes."""
+ project = Project.create(rows_per_pattern=rows_per_pattern, settings=settings)
+ project.voices.append(instrument)
+ return project
+
+
+def place_instrument(
+ project: Project,
+ *,
+ channel_name: ChannelName,
+ row_index: int,
+ sample: VoiceUnion,
+ transpose: Optional[int] = None,
+ volume: Optional[int] = None,
+ pattern_index: int = 0,
+) -> None:
+ """Writes a note column naming ``sample`` onto one row of one channel's pattern."""
+ pattern = project.song.channels[channel_name].ensure_pattern(
+ pattern_index,
+ project.song.rows_per_pattern,
+ )
+ pattern.rows[row_index] = Row(
+ command=NoteOn(voice_id=sample.id),
+ transpose=transpose,
+ volume=volume,
+ )
diff --git a/tests/suite/player.py b/tests/suite/player.py
new file mode 100644
index 000000000..b0fb0df5a
--- /dev/null
+++ b/tests/suite/player.py
@@ -0,0 +1,333 @@
+import os
+from pathlib import Path
+from typing import Dict, Final, List, Optional, Sequence
+
+import numpy as np
+
+from sampletones_core.configs import Config
+from sampletones_core.constants.enums import ChannelName
+from sampletones_core.constants.general import DUTY_CYCLES
+from sampletones_core.exporters import Features
+from sampletones_core.exports.request import InstrumentExport, SampleExport
+from sampletones_core.features.envelope import Envelope
+from sampletones_core.instructions import InstructionUnion, PulseInstruction
+from sampletones_core.reconstructions import Reconstruction
+from sampletones_core.timers.utils import get_timer_table
+from sampletones_player.clock.schedule import PlaySchedule
+from sampletones_player.compression.compressed import CompressedPlanes
+from sampletones_player.compression.dictionary.table import PhraseTable
+from sampletones_player.compression.encode import emit, encode_planes
+from sampletones_player.compression.options import EVERY_LAYER
+from sampletones_player.compression.pitch import PITCH_COUNT, PitchTable
+from sampletones_player.compression.planes.channel import TonePlanes
+from sampletones_player.compression.planes.order import PlaneOrder
+from sampletones_player.compression.planes.separate import planes_from_streams
+from sampletones_player.compression.planes.song import SongPlanes
+from sampletones_player.compression.tokens.literal import LiteralToken
+from sampletones_player.registers.noise import NoiseRegisters
+from sampletones_player.registers.pulse import PulseRegisters
+from sampletones_player.registers.streams import ChannelStreams
+from sampletones_player.registers.triangle import TriangleRegisters
+from sampletones_player.song import Song
+from sampletones_player.specification.binary import unsigned_byte
+from sampletones_player.specification.compression import (
+ MAX_LITERAL_BYTES,
+ PLANE_COUNT,
+)
+from sampletones_player.specification.registers import (
+ DUTY_CYCLE_SHIFT,
+ MAX_REGISTER_VALUE,
+ NOISE_MODE_SHIFT,
+ SUSTAINED_LEVEL,
+ TIMER_HIGH_SHIFT,
+ TRIANGLE_COUNTER_CONTROL,
+ TRIANGLE_SILENT_RELOAD,
+ TRIANGLE_SOUNDING_RELOAD,
+)
+from sampletones_shared.constants.music import OCTAVE_SEMITONES
+from sampletones_shared.music import Tuning
+from tests.suite.stems import single_entry_stems_data
+
+PLAYER_TUNING: Final[Tuning] = Tuning()
+PLAYER_PITCHES: Final[PitchTable] = PitchTable.from_tuning(PLAYER_TUNING)
+PLAYER_TIMER_TABLE: Final[Dict[int, int]] = get_timer_table(PLAYER_TUNING)
+
+PLAYER_REFERENCE_PITCH: Final[int] = 57
+PLAYER_OCTAVE_UP_PITCH: Final[int] = PLAYER_REFERENCE_PITCH + OCTAVE_SEMITONES
+PLAYER_REFERENCE_TIMER: Final[int] = PLAYER_TIMER_TABLE[PLAYER_REFERENCE_PITCH]
+PLAYER_OCTAVE_UP_TIMER: Final[int] = PLAYER_TIMER_TABLE[PLAYER_OCTAVE_UP_PITCH]
+PLAYER_REFERENCE_PERIOD: Final[int] = 0x0A
+PLAYER_FULL_VOLUME: Final[int] = 15
+PLAYER_SILENT_VOLUME: Final[int] = 0
+
+
+def pulse_tick(
+ volume: int,
+ duty_cycle: int,
+ timer: int,
+) -> PulseRegisters:
+ """A pulse channel's registers for one tick, spelled the way the encoder spells them."""
+ return PulseRegisters(
+ control=(duty_cycle << DUTY_CYCLE_SHIFT) | SUSTAINED_LEVEL | volume,
+ timer_low=timer & MAX_REGISTER_VALUE,
+ timer_high=timer >> TIMER_HIGH_SHIFT,
+ )
+
+
+def triangle_tick(sounding: bool, timer: int) -> TriangleRegisters:
+ """A triangle channel's registers for one tick, spelled the way the encoder spells them."""
+ reload_value = TRIANGLE_SOUNDING_RELOAD if sounding else TRIANGLE_SILENT_RELOAD
+ return TriangleRegisters(
+ linear_counter=TRIANGLE_COUNTER_CONTROL | reload_value,
+ timer_low=timer & MAX_REGISTER_VALUE,
+ timer_high=timer >> TIMER_HIGH_SHIFT,
+ )
+
+
+def noise_tick(
+ volume: int,
+ mode: int,
+ register_period: int,
+) -> NoiseRegisters:
+ """A noise channel's registers for one tick, its period counted the way ``$400E`` counts it."""
+ return NoiseRegisters(
+ control=SUSTAINED_LEVEL | volume,
+ period=(mode << NOISE_MODE_SHIFT) | register_period,
+ )
+
+
+def player_streams(
+ pulse1: Sequence[PulseRegisters],
+ pulse2: Sequence[PulseRegisters],
+ triangle: Sequence[TriangleRegisters],
+ noise: Sequence[NoiseRegisters],
+) -> ChannelStreams:
+ return ChannelStreams(
+ pulse1=tuple(pulse1),
+ pulse2=tuple(pulse2),
+ triangle=tuple(triangle),
+ noise=tuple(noise),
+ )
+
+
+def resting_streams(pulse1: Sequence[PulseRegisters]) -> ChannelStreams:
+ """Streams where one pulse channel carries the song and the other three rest on a single tick."""
+ return player_streams(
+ pulse1=pulse1,
+ pulse2=(pulse_tick(PLAYER_SILENT_VOLUME, 0, PLAYER_REFERENCE_TIMER),),
+ triangle=(triangle_tick(False, PLAYER_REFERENCE_TIMER),),
+ noise=(noise_tick(PLAYER_SILENT_VOLUME, 0, PLAYER_REFERENCE_PERIOD),),
+ )
+
+
+def player_song(
+ streams: ChannelStreams,
+ nes_frequency: int,
+ loop_tick: Optional[int],
+) -> Song:
+ """A song the console plays those streams as, compressed the way an exported one is."""
+ return Song.from_streams(
+ streams=streams,
+ pitches=PLAYER_PITCHES,
+ schedule=PlaySchedule.from_parameters(nes_frequency),
+ loop_tick=loop_tick,
+ seeds=(),
+ )
+
+
+def bent_song(
+ pitch_index: int,
+ bends: Sequence[int],
+ nes_frequency: int,
+) -> Song:
+ """A song holding one note on a pulse channel while its bend plane moves the divider.
+
+ A bend reaches the console on a plane of its own, and only the plane can put one there while
+ the encoders still leave the dimension to the note. Stating one outright is therefore what
+ holds the driver's own arithmetic to the divider each tick is meant to sound at.
+
+ Args:
+ pitch_index: The pitch the value plane names, counted from the lowest the table holds.
+ bends: The divider steps each tick stands away from that pitch.
+ nes_frequency: The rate the streams were written at.
+
+ Returns:
+ Song: The song, its other channels resting throughout.
+ """
+ sounding = pulse_tick(PLAYER_FULL_VOLUME, 0, PLAYER_PITCHES.timers[pitch_index])
+ planes = planes_from_streams(resting_streams((sounding,) * len(bends)), PLAYER_PITCHES)
+ bent = SongPlanes(
+ pulse1=TonePlanes(
+ control=planes.pulse1.control,
+ value=planes.pulse1.value,
+ bend=bytes(unsigned_byte(bend) for bend in bends),
+ ),
+ pulse2=planes.pulse2,
+ triangle=planes.triangle,
+ noise=planes.noise,
+ )
+ return Song(
+ planes=encode_planes(bent, (), options=EVERY_LAYER, boundaries=frozenset()),
+ pitches=PLAYER_PITCHES,
+ schedule=PlaySchedule.from_parameters(nes_frequency),
+ loop_tick=None,
+ )
+
+
+PLAYER_PULSE_TIMER_MUTE_FLOOR: Final[int] = 8
+
+
+def sounding_pulse(
+ pitch: int,
+ volume: int,
+ duty_cycle: int,
+) -> PulseInstruction:
+ return PulseInstruction(
+ on=True,
+ pitch=pitch,
+ volume=volume,
+ duty_cycle=duty_cycle,
+ )
+
+
+def silent_pulse() -> PulseInstruction:
+ return PulseInstruction.null_instruction()
+
+
+PLAYER_VARIED_SEED: Final[int] = 7
+
+
+def spelled_song(ticks: int, nes_frequency: int) -> Song:
+ """A song whose every plane spells its values out, which is the most room a song can take.
+
+ A block reaching past what the console holds is what a refusal is measured on, and a plane
+ the codec finds nothing in is where a song takes most room: one byte a tick and an opcode
+ every sixty-four. Building the streams outright states that shape exactly.
+ """
+ values = bytes(tick % PITCH_COUNT for tick in range(ticks))
+ stream = emit(
+ [
+ LiteralToken(values=values[start : start + MAX_LITERAL_BYTES])
+ for start in range(0, len(values), MAX_LITERAL_BYTES)
+ ]
+ )
+ return Song(
+ planes=CompressedPlanes(
+ phrases=PhraseTable(phrases=()),
+ streams=PlaneOrder.across((stream,) * PLANE_COUNT),
+ ticks=ticks,
+ ),
+ pitches=PLAYER_PITCHES,
+ schedule=PlaySchedule.from_parameters(nes_frequency),
+ loop_tick=None,
+ )
+
+
+PLAYER_APPROXIMATION_SAMPLES: Final[int] = 64
+
+
+def player_reconstruction(
+ instructions: Dict[ChannelName, List[InstructionUnion]],
+ nes_frequency: int,
+) -> Reconstruction:
+ """A reconstruction carrying the given channel streams, built at ``nes_frequency``.
+
+ The audio itself is silent, since what a player test reads off a reconstruction is the
+ instructions its channels carry and the rate they advance at.
+ """
+ config = Config().with_library(nes_frequency=nes_frequency)
+ return Reconstruction.create(
+ approximation=np.zeros(PLAYER_APPROXIMATION_SAMPLES, dtype=np.float32),
+ approximations={},
+ instructions=instructions,
+ config=config,
+ coefficient=1.0,
+ audio_filepath=(Path(os.devnull),),
+ stems_data=single_entry_stems_data(list(config.generation.channels), instructions),
+ )
+
+
+def player_features(
+ frames: int,
+ pitch: int,
+ *,
+ duty_cycle: bool,
+) -> Features:
+ """Envelopes sounding one pitch at full volume for ``frames`` ticks."""
+ return Features(
+ initial_pitch=pitch,
+ volume=Envelope(items=(PLAYER_FULL_VOLUME,) * frames),
+ arpeggio=Envelope(items=(0,) * frames),
+ pitch=None,
+ hi_pitch=None,
+ duty_cycle=Envelope(items=(0,) * frames) if duty_cycle else None,
+ )
+
+
+def varied_features(
+ frames: int,
+ pitch: int,
+ *,
+ duty_cycle: bool,
+) -> Features:
+ """Envelopes turning over at every tick, the shape a plane holds least to repeat.
+
+ A song outgrowing the console is a song the codec finds little in, so the envelopes are
+ drawn at random from a stated seed: the same shape every run, and one that repeats nowhere.
+ """
+ generator = np.random.default_rng(PLAYER_VARIED_SEED)
+ return Features(
+ initial_pitch=pitch,
+ volume=Envelope(items=tuple(generator.integers(0, PLAYER_FULL_VOLUME + 1, frames).tolist())),
+ arpeggio=Envelope(items=tuple(generator.integers(-OCTAVE_SEMITONES, OCTAVE_SEMITONES + 1, frames).tolist())),
+ pitch=None,
+ hi_pitch=None,
+ duty_cycle=(
+ Envelope(items=tuple(generator.integers(0, len(DUTY_CYCLES), frames).tolist())) if duty_cycle else None
+ ),
+ )
+
+
+def player_instrument(
+ name: str,
+ channel: ChannelName,
+ features: Features,
+ *,
+ nes_frequency: int,
+ loop: bool,
+ tuning: Tuning = PLAYER_TUNING,
+) -> InstrumentExport:
+ """One channel slice of an export request."""
+ return InstrumentExport(
+ name=name,
+ channel=channel,
+ features=looping_features(features) if loop else features,
+ nes_frequency=nes_frequency,
+ tuning=tuning,
+ )
+
+
+def looping_features(features: Features) -> Features:
+ """The envelopes with every dimension they write circling from its first item."""
+ looping = features
+ for feature_key, envelope in features.envelopes.items():
+ if envelope.written:
+ looping = looping.with_envelope(feature_key, envelope.model_copy(update={"loop_point": 0}))
+
+ return looping
+
+
+def player_sample(
+ name: str,
+ instruments: Sequence[InstrumentExport],
+ *,
+ nes_frequency: int,
+ tuning: Tuning = PLAYER_TUNING,
+) -> SampleExport:
+ """Every channel slice of one reconstruction, as an export request carries them."""
+ return SampleExport(
+ name=name,
+ instruments=tuple(instruments),
+ nes_frequency=nes_frequency,
+ tuning=tuning,
+ )
diff --git a/tests/suite/progress.py b/tests/suite/progress.py
new file mode 100644
index 000000000..e326d6bc6
--- /dev/null
+++ b/tests/suite/progress.py
@@ -0,0 +1,53 @@
+from typing import Final, Generic, List, Optional, Protocol, Sequence, TypeVar
+
+ProgressT = TypeVar("ProgressT")
+StageT = TypeVar("StageT", covariant=True)
+
+NEVER_WITHDRAWN: Final[Optional[int]] = None
+FIRST_REPORT: Final[int] = 1
+
+
+class RecordingReporter(Generic[ProgressT]):
+ """Keeps every report a run offers, and withdraws the run at a chosen one.
+
+ A run reports itself so a caller can watch it and decide whether it goes on, so a test of
+ one asks two things: what the run said about itself, and what it did once told to stop.
+ Both are answered here, the second by counting the reports and refusing at the one named.
+ """
+
+ def __init__(self, withdraw_at: Optional[int] = NEVER_WITHDRAWN) -> None:
+ self.reports: List[ProgressT] = []
+ self._withdraw_at = withdraw_at
+
+ def __call__(self, progress: ProgressT) -> bool:
+ self.reports.append(progress)
+ return len(self.reports) != self._withdraw_at
+
+ @property
+ def last(self) -> ProgressT:
+ """The report the run finished on."""
+ return self.reports[-1]
+
+
+class StagedProgress(Protocol[StageT]):
+ """A report that names the work it comes from, whatever that work counts in."""
+
+ @property
+ def stage(self) -> StageT: ...
+
+
+def reported_stages(reports: Sequence[StagedProgress[StageT]]) -> List[StageT]:
+ """The stages a run reached, in order, a stretch spent in one counted once.
+
+ Args:
+ reports: What the run said about itself, in the order it said it.
+
+ Returns:
+ List[StageT]: The stages, each entry a stage the run moved into.
+ """
+ reached: List[StageT] = []
+ for report in reports:
+ if not reached or reached[-1] != report.stage:
+ reached.append(report.stage)
+
+ return reached
diff --git a/tests/suite/release.py b/tests/suite/release.py
new file mode 100644
index 000000000..7ef7b4e99
--- /dev/null
+++ b/tests/suite/release.py
@@ -0,0 +1,25 @@
+import time
+from pathlib import Path
+from typing import Final
+
+RELEASE_POLL_SECONDS: Final[float] = 0.01
+RELEASE_TIMEOUT_SECONDS: Final[float] = 30.0
+
+
+def wait_for_release(release_path: Path) -> None:
+ """Holds a worker until the test that started it writes ``release_path``.
+
+ A test asserting that a reading arrived while work was still under way has to know the work
+ was still under way when it looked, and a task quick enough to finish first would leave that
+ assertion resting on the scheduler. Holding the task where the test wants to see it settles
+ the question.
+
+ The wait gives up after ``RELEASE_TIMEOUT_SECONDS`` and lets the task finish, so a test that
+ never releases it fails on its own assertion rather than holding a run open.
+
+ Args:
+ release_path: The file whose appearance lets the worker carry on.
+ """
+ deadline = time.monotonic() + RELEASE_TIMEOUT_SECONDS
+ while not release_path.exists() and time.monotonic() < deadline:
+ time.sleep(RELEASE_POLL_SECONDS)
diff --git a/tests/suite/render.py b/tests/suite/render.py
index 5831f05ff..8f51ba81a 100644
--- a/tests/suite/render.py
+++ b/tests/suite/render.py
@@ -5,7 +5,7 @@
from sampletones_application.logic.sequencer.playback.synthesizer import RowSynthesizer
from sampletones_application.services.render.result import RenderResult
from sampletones_application.services.result import (
- ServiceCancelled,
+ ServiceCanceled,
ServiceError,
ServiceSuccess,
)
@@ -74,7 +74,7 @@ def shutdown(self) -> None:
def emit(self, result: RenderResult) -> None:
assert self._handler is not None, "The logic subscribes to the service it is given"
- self.running = not isinstance(result, (ServiceSuccess, ServiceError, ServiceCancelled))
+ self.running = not isinstance(result, (ServiceSuccess, ServiceError, ServiceCanceled))
self._handler(result)
@property
diff --git a/tests/suite/scenario.py b/tests/suite/scenario.py
index 29e4375d7..fd99af604 100644
--- a/tests/suite/scenario.py
+++ b/tests/suite/scenario.py
@@ -26,7 +26,7 @@ class BaseTestScenario(Generic[ContextT]):
Unlike a :class:`BaseTestCase`, which describes a single input/output pair, a
scenario models a stateful sequence of operations: ``build`` produces the
initial context, then :meth:`run` threads it through each step in turn. This
- fits behaviours that only emerge over a sequence of mutations -- reordering a
+ fits behaviors that only emerge over a sequence of mutations -- reordering a
collection, editing an item in place, and asserting that references survive.
"""
diff --git a/tests/suite/sequencer.py b/tests/suite/sequencer.py
index ff4b13f71..14c36bc4a 100644
--- a/tests/suite/sequencer.py
+++ b/tests/suite/sequencer.py
@@ -4,23 +4,30 @@
import numpy as np
from sampletones_application.logic.project.controller import ProjectController
-from sampletones_application.logic.sequencer.order import OrderBlock, SequencerOrderLogic
+from sampletones_application.logic.sequencer.order import (
+ OrderBlock,
+ SequencerOrderLogic,
+)
from sampletones_application.logic.sequencer.order.block import BlockKey as OrderBlockKey
-from sampletones_application.logic.sequencer.tracker import BlockNote, SequencerTrackerLogic, TrackerBlock
+from sampletones_application.logic.sequencer.tracker import (
+ BlockNote,
+ SequencerTrackerLogic,
+ TrackerBlock,
+)
from sampletones_application.logic.sequencer.tracker.block import BlockKey
from sampletones_application.view_model.sequencer.slot import SUBCOLUMNS
from sampletones_application.view_model.sequencer.subcolumn import SubColumn
from sampletones_core.configs import Config
-from sampletones_core.constants.enums import GeneratorName
+from sampletones_core.constants.enums import ChannelName
from sampletones_core.instructions import (
InstructionUnion,
NoiseInstruction,
PulseInstruction,
TriangleInstruction,
)
-from sampletones_core.project.instruments.instrument import Instrument
-from sampletones_core.project.instruments.note_off import NoteOff
from sampletones_core.project.patterns.row import NoteCommand
+from sampletones_core.project.voices.note_off import NoteOff
+from sampletones_core.project.voices.note_on import NoteOn
from sampletones_core.reconstructions import Reconstruction
from sampletones_core.utils.display import (
BLANK,
@@ -28,9 +35,10 @@
NOTE_OFF,
display_id,
)
+from sampletones_shared.constants.general import HEXADECIMAL_BASE
from sampletones_shared.constants.symbols import MINUS, MIXED, PLUS
+from tests.suite.stems import single_entry_stems_data
-SAMPLE_LENGTH: Final[int] = 64
SAMPLE_PITCH: Final[int] = 60
SAMPLE_VOLUME: Final[int] = 8
SAMPLE_PERIOD: Final[int] = 4
@@ -40,26 +48,30 @@
UNKNOWN_SAMPLE_ID: Final[str] = "a-sample-no-project-holds"
-def sample_reconstruction(generators: Sequence[GeneratorName]) -> Reconstruction:
- """A reconstruction carrying one instruction on each of ``generators``.
+def sample_reconstruction(channels: Sequence[ChannelName]) -> Reconstruction:
+ """A reconstruction carrying one instruction on each of ``channels``.
The channels a reconstruction covers are what a sample governs in the sequencer, so this is
the knob a sequencer test turns: the audio itself is silent, since what is under test is which
channels a sample reaches and not how it sounds.
- Each channel carries the instruction its own generator sounds, since the instruction type is
+ Each channel carries the instruction its own channel sounds, since the instruction type is
what names the exporter a channel is read through — so a reading taken off this reconstruction
- is the reading the channel gives.
+ is the reading the channel gives. The audio spans one frame per instruction, which keeps the
+ stems record the reconstruction carries parallel to the stored waveforms.
"""
- instructions = {generator: [_instruction(generator)] for generator in generators}
- approximations = {generator: np.zeros(SAMPLE_LENGTH, dtype=np.float32) for generator in generators}
+ config = Config()
+ length = config.library.frame_length
+ instructions = {channel: [_instruction(channel)] for channel in channels}
+ approximations = {channel: np.zeros(length, dtype=np.float32) for channel in channels}
return Reconstruction.create(
- approximation=np.zeros(SAMPLE_LENGTH, dtype=np.float32),
+ approximation=np.zeros(length, dtype=np.float32),
approximations=approximations,
instructions=instructions,
- config=Config(),
+ config=config,
coefficient=1.0,
- audio_filepath=Path("/dev/null"),
+ audio_filepath=(Path("/dev/null"),),
+ stems_data=single_entry_stems_data(list(config.generation.channels), instructions),
)
@@ -67,13 +79,12 @@ def render_frame(tracker_logic: SequencerTrackerLogic) -> Tuple[str, ...]:
"""Every row of the frame shown, each read as the four channel cells the grid draws.
A row is written the way it appears on screen, so an expectation and a screenshot read alike.
- The sample column is left out because it holds nothing of its own: it summarises these four,
+ The sample column is left out because it holds nothing of its own: it summarizes these four,
and stating it again would pin the summary rather than what a gesture wrote.
"""
grid = tracker_logic.build_grid()
return tuple(
- f" {COLUMN_SEPARATOR} ".join(row.cells[generator].label for generator in GeneratorName.items())
- for row in grid.rows
+ f" {COLUMN_SEPARATOR} ".join(row.cells[channel].label for channel in ChannelName.items()) for row in grid.rows
)
@@ -87,20 +98,20 @@ def render_slots(
pattern the channel had not held before.
"""
frame = controller.project.song.order[frame_index]
- return " ".join(display_id(frame.get(generator)) for generator in GeneratorName.items())
+ return " ".join(display_id(frame.get(channel)) for channel in ChannelName.items())
def render_order(order_logic: SequencerOrderLogic) -> Tuple[str, ...]:
"""Every channel's row of the order, each read as the pattern indices the table draws.
A row is written the way it appears on screen, so an expectation and a screenshot read alike.
- The master row is left out because it holds nothing of its own: it summarises these four, and
+ The master row is left out because it holds nothing of its own: it summarizes these four, and
stating it again would pin the summary rather than what a gesture wrote.
"""
view_model = order_logic.build_order()
return tuple(
- " ".join(view_model.entry_label(generator, position) for position in range(view_model.position_count))
- for generator in GeneratorName.items()
+ " ".join(view_model.entry_label(channel, position) for position in range(view_model.position_count))
+ for channel in ChannelName.items()
)
@@ -142,9 +153,9 @@ def fill_order(
for _ in range(reach - order_logic.position_count()):
order_logic.append_frame()
- for generator, tokens in zip(GeneratorName.items(), lines):
+ for channel, tokens in zip(ChannelName.items(), lines):
for position, token in enumerate(tokens):
- order_logic.set_order_entry(generator, position, parse_index(token))
+ order_logic.set_order_entry(channel, position, parse_index(token))
def parse_index(token: str) -> Optional[int]:
@@ -152,14 +163,14 @@ def parse_index(token: str) -> Optional[int]:
if token == display_id(None):
return None
- return int(token, 16)
+ return int(token, HEXADECIMAL_BASE)
def parse_block(
rows: Sequence[str],
*,
first_subcolumn: SubColumn,
- sample_ids: Sequence[str],
+ voice_ids: Sequence[str],
) -> TrackerBlock:
"""Reads a block written the way the grid draws it, one line per row.
@@ -168,7 +179,7 @@ def parse_block(
the block begins on. A ``?`` states that the block says nothing about that cell, which is what
leaves it out of the maps entirely.
- A note names its sample by the position the grid prints, resolved through ``sample_ids``;
+ A note names its sample by the position the grid prints, resolved through ``voice_ids``;
``!!`` names a sample no project holds.
Raises:
@@ -191,8 +202,8 @@ def parse_block(
continue
match SUBCOLUMNS[slot_offset % len(SUBCOLUMNS)]:
- case SubColumn.INSTRUMENT:
- notes[key] = parse_note(token, sample_ids)
+ case SubColumn.VOICE:
+ notes[key] = parse_note(token, voice_ids)
case SubColumn.TRANSPOSE:
transposes[key] = parse_transpose(token)
case SubColumn.VOLUME:
@@ -209,7 +220,7 @@ def fill_frame(
tracker_logic: SequencerTrackerLogic,
rows: Sequence[str],
*,
- sample_ids: Sequence[str],
+ voice_ids: Sequence[str],
) -> None:
"""Writes a frame stated the way the grid draws it, one channel cell at a time.
@@ -218,19 +229,19 @@ def fill_frame(
exercised it.
"""
for row_index, line in enumerate(rows):
- for generator, cell in zip(GeneratorName.items(), line.split(COLUMN_SEPARATOR)):
+ for channel, cell in zip(ChannelName.items(), line.split(COLUMN_SEPARATOR)):
_fill_cell(
tracker_logic,
row_index,
- generator,
+ channel,
cell.split(),
- sample_ids,
+ voice_ids,
)
def parse_note(
token: str,
- sample_ids: Sequence[str],
+ voice_ids: Sequence[str],
) -> Optional[BlockNote]:
"""The note a token names: a sample by the position it prints, a cut, or emptiness."""
if token == display_id(None):
@@ -242,14 +253,14 @@ def parse_note(
if token == UNKNOWN_SAMPLE:
return UNKNOWN_SAMPLE_ID
- return sample_ids[int(token, 16)]
+ return voice_ids[int(token, HEXADECIMAL_BASE)]
def parse_transpose(token: str) -> Optional[int]:
if token == NOTE_BLANK:
return None
- magnitude = int(token[1:], 16)
+ magnitude = int(token[1:], HEXADECIMAL_BASE)
return -magnitude if token.startswith(MINUS) else magnitude
@@ -257,22 +268,22 @@ def parse_volume(token: str) -> Optional[int]:
if token == BLANK:
return None
- return int(token, 16)
+ return int(token, HEXADECIMAL_BASE)
-def _instruction(generator: GeneratorName) -> InstructionUnion:
- """The instruction a channel sounds, which is the type its generator and exporter pair with.
+def _instruction(channel: ChannelName) -> InstructionUnion:
+ """The instruction a channel sounds, which is the type its channel and exporter pair with.
The two pulse channels share the pulse instruction; the triangle and the noise each take their
own.
"""
- match generator:
- case GeneratorName.TRIANGLE:
+ match channel:
+ case ChannelName.TRIANGLE:
return TriangleInstruction(
on=True,
pitch=SAMPLE_PITCH,
)
- case GeneratorName.NOISE:
+ case ChannelName.NOISE:
return NoiseInstruction(
on=True,
period=SAMPLE_PERIOD,
@@ -291,25 +302,25 @@ def _instruction(generator: GeneratorName) -> InstructionUnion:
def _fill_cell(
tracker_logic: SequencerTrackerLogic,
row_index: int,
- generator: GeneratorName,
+ channel: ChannelName,
tokens: Sequence[str],
- sample_ids: Sequence[str],
+ voice_ids: Sequence[str],
) -> None:
"""Writes the values one channel cell states, passing over a cell that states none.
A cell is written whole where it carries anything, so the row it lands on materialises exactly
once however many of its subcolumns hold a value.
"""
- note = parse_note(tokens[0], sample_ids)
+ note = parse_note(tokens[0], voice_ids)
transpose = parse_transpose(tokens[1])
volume = parse_volume(tokens[2])
if note is None and transpose is None and volume is None:
return
tracker_logic.set_row(
- generator,
+ channel,
row_index,
- command=_command(note, generator),
+ command=_command(note, channel),
transpose=transpose,
volume=volume,
)
@@ -317,14 +328,14 @@ def _fill_cell(
def _command(
note: Optional[BlockNote],
- generator: GeneratorName,
+ channel: ChannelName,
) -> Optional[NoteCommand]:
"""The command a note becomes in the channel it is written to, which is what carries its pitch."""
match note:
case NoteOff():
return note
- case str() as sample_id:
- return Instrument(sample_id=sample_id, generator_name=generator)
+ case str() as voice_id:
+ return NoteOn(voice_id=voice_id)
case None:
return None
diff --git a/tests/suite/source.py b/tests/suite/source.py
index 003626b48..86a038f75 100644
--- a/tests/suite/source.py
+++ b/tests/suite/source.py
@@ -1,8 +1,10 @@
import ast
+from pathlib import Path
from textwrap import dedent
-from typing import Iterable
+from typing import Iterable, Set
from sampletones_shared.meta.source.bindings.scopes import Scope
+from sampletones_shared.meta.source.modules import source_paths
def parse_source(source: str) -> ast.Module:
@@ -28,3 +30,36 @@ def scope_named(scopes: Iterable[Scope], name: str) -> Scope:
return scope
raise AssertionError(f"the scopes hold no function named {name}")
+
+
+def write_module(directory: Path, name: str, body: str) -> Path:
+ """Writes one module into a tree a test builds, opening the directories it sits under.
+
+ Args:
+ directory: Directory the module belongs in.
+ name: File name to write it under.
+ body: Source the module holds.
+
+ Returns:
+ Path: Where the module was written.
+ """
+ directory.mkdir(parents=True, exist_ok=True)
+ path = directory / name
+ path.write_text(body, encoding="utf-8")
+ return path
+
+
+def swept_paths(root: Path) -> Set[Path]:
+ """The resolved modules a sweep of one tree reads, the form a rule is held to.
+
+ Args:
+ root: Tree to sweep.
+
+ Returns:
+ Set[Path]: Every visible module under the tree, resolved.
+
+ Raises:
+ NotADirectoryError: If the root names no directory.
+ FileNotFoundError: If the root holds no module to read.
+ """
+ return {path.resolve() for path in source_paths([root])}
diff --git a/tests/suite/stems.py b/tests/suite/stems.py
new file mode 100644
index 000000000..3768392e2
--- /dev/null
+++ b/tests/suite/stems.py
@@ -0,0 +1,23 @@
+from typing import List, Mapping, Sequence
+
+from sampletones_core.constants.algorithm import DEFAULT_STEMS_CHANNEL_CAP
+from sampletones_core.constants.enums import ChannelName
+from sampletones_core.instructions import InstructionUnion
+from sampletones_core.reconstructions.reconstruction.stems.channel_assignment import ChannelAssignment
+from sampletones_core.reconstructions.reconstruction.stems.data import StemsData
+
+
+def single_entry_stems_data(
+ channels: List[ChannelName],
+ instructions: Mapping[ChannelName, Sequence[InstructionUnion]],
+) -> StemsData:
+ """The single-entry record for ``channels``, stem 0 owning each frame that plays."""
+ assignments = [
+ ChannelAssignment(channel_name=channel_name, stem_ids=[0] * len(stream))
+ for channel_name, stream in instructions.items()
+ if stream
+ ]
+ return StemsData.single_entry(
+ channels,
+ assignments,
+ )
diff --git a/tests/unit/sampletones_application/categories/test_trackers.py b/tests/unit/sampletones_application/categories/test_exports.py
similarity index 56%
rename from tests/unit/sampletones_application/categories/test_trackers.py
rename to tests/unit/sampletones_application/categories/test_exports.py
index 7f42d3e7f..b3518c506 100644
--- a/tests/unit/sampletones_application/categories/test_trackers.py
+++ b/tests/unit/sampletones_application/categories/test_exports.py
@@ -2,47 +2,47 @@
import pytest
-from sampletones_application.categories.trackers import (
+from sampletones_application.categories.exports import (
+ EXPORT_INSTRUMENT_FILTERS,
+ EXPORT_PROJECT_ELEMENTS,
+ EXPORT_PROJECT_MENU_LABELS,
+ EXPORT_SAMPLE_MENU_LABELS,
INSTRUMENT_EXPORT_FORMATS,
- TRACKER_INSTRUMENT_FILTERS,
- TRACKER_PROJECT_ELEMENTS,
- TRACKER_PROJECT_MENU_LABELS,
- TRACKER_SAMPLE_MENU_LABELS,
)
+from sampletones_application.exports import build_export_backends
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
+from sampletones_core.exports.backend import ExportBackend
+from sampletones_core.exports.format import ExportFormat
+from sampletones_core.exports.scope import ExportScope
@pytest.fixture(name="backends")
-def backends_fixture() -> Dict[TrackerFormat, TrackerBackend]:
- return build_tracker_backends()
+def backends_fixture() -> Dict[ExportFormat, ExportBackend]:
+ return build_export_backends()
def formats_supporting(
- backends: Dict[TrackerFormat, TrackerBackend],
+ backends: Dict[ExportFormat, ExportBackend],
scope: ExportScope,
-) -> Set[TrackerFormat]:
- return {tracker_format for tracker_format, backend in backends.items() if scope in backend.supported_scopes}
+) -> Set[ExportFormat]:
+ return {export_format for export_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."""
+ """A menu entry reaches a backend through the registry the composition root builds, so an
+ entry that 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(EXPORT_PROJECT_MENU_LABELS),
+ frozenset(EXPORT_PROJECT_ELEMENTS),
frozenset(SAMPLE_EXPORT_SHORTCUT_IDS),
- frozenset(TRACKER_SAMPLE_MENU_LABELS),
+ frozenset(EXPORT_SAMPLE_MENU_LABELS),
frozenset(INSTRUMENT_EXPORT_FORMATS),
],
ids=[
@@ -56,8 +56,8 @@ class TestEveryOfferedFormatHasABackend:
)
def test_the_registry_builds_every_offered_format(
self,
- backends: Dict[TrackerFormat, TrackerBackend],
- offered: FrozenSet[TrackerFormat],
+ backends: Dict[ExportFormat, ExportBackend],
+ offered: FrozenSet[ExportFormat],
) -> None:
assert offered <= frozenset(backends)
@@ -72,19 +72,19 @@ class TestTheMenusMatchTheSupportedScopes:
def test_the_project_export_menu_lists_the_formats_that_write_a_project(
self,
- backends: Dict[TrackerFormat, TrackerBackend],
+ backends: Dict[ExportFormat, ExportBackend],
) -> None:
- assert set(TRACKER_PROJECT_MENU_LABELS) == formats_supporting(backends, ExportScope.PROJECT)
+ assert set(EXPORT_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],
+ backends: Dict[ExportFormat, ExportBackend],
) -> None:
- assert set(TRACKER_SAMPLE_MENU_LABELS) <= formats_supporting(backends, ExportScope.SAMPLE)
+ assert set(EXPORT_SAMPLE_MENU_LABELS) <= formats_supporting(backends, ExportScope.SAMPLE)
def test_the_instrument_button_offers_formats_that_write_one_slice(
self,
- backends: Dict[TrackerFormat, TrackerBackend],
+ backends: Dict[ExportFormat, ExportBackend],
) -> None:
assert set(INSTRUMENT_EXPORT_FORMATS) <= formats_supporting(backends, ExportScope.INSTRUMENT)
@@ -94,10 +94,10 @@ class TestEveryMenuEntryCarriesAnAction:
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)
+ assert set(EXPORT_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)
+ assert set(EXPORT_SAMPLE_MENU_LABELS) == set(SAMPLE_EXPORT_SHORTCUT_IDS)
class TestEveryOfferedFormatIsNamedInItsDialog:
@@ -105,5 +105,5 @@ class TestEveryOfferedFormatIsNamedInItsDialog:
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)
+ offered = set(INSTRUMENT_EXPORT_FORMATS) | set(EXPORT_SAMPLE_MENU_LABELS)
+ assert offered <= set(EXPORT_INSTRUMENT_FILTERS)
diff --git a/tests/unit/sampletones_application/categories/test_instrument.py b/tests/unit/sampletones_application/categories/test_instrument.py
new file mode 100644
index 000000000..ef6f74caf
--- /dev/null
+++ b/tests/unit/sampletones_application/categories/test_instrument.py
@@ -0,0 +1,74 @@
+import pytest
+
+from sampletones_application.categories.instrument import (
+ OMISSION_BULLET,
+ OMISSION_ELEMENTS,
+ InstrumentImportMessages,
+)
+from sampletones_application.categories.manager import LanguageManager
+from sampletones_application.paths import LANG_EN
+from sampletones_core.formats.famitracker.voice import InstrumentOmission
+
+
+@pytest.fixture
+def messages() -> InstrumentImportMessages:
+ return InstrumentImportMessages.build(LanguageManager(LANG_EN))
+
+
+class TestEveryDimensionIsNamed:
+ """A dimension a file states past the voice reaches the reader in words, never as an enum."""
+
+ def test_each_one_a_reader_can_meet_carries_words(self, messages: InstrumentImportMessages) -> None:
+ assert set(messages.omissions) == set(InstrumentOmission)
+ assert all(messages.omissions[omission] for omission in InstrumentOmission)
+
+ def test_each_one_reads_differently_from_the_rest(self, messages: InstrumentImportMessages) -> None:
+ assert len(set(messages.omissions.values())) == len(InstrumentOmission)
+
+ def test_the_map_answers_for_every_dimension(self) -> None:
+ assert set(OMISSION_ELEMENTS) == set(InstrumentOmission)
+
+
+class TestTheNotice:
+ def test_a_file_stating_the_voice_alone_is_reported_nowhere(
+ self,
+ messages: InstrumentImportMessages,
+ ) -> None:
+ """An import that lost nothing has nothing to say, so no dialog interrupts it."""
+ assert messages.notice("Lead", ()) is None
+
+ def test_the_voice_is_named_in_the_opening(self, messages: InstrumentImportMessages) -> None:
+ notice = messages.notice("Lead", (InstrumentOmission.CUMULATIVE_BEND,))
+
+ assert notice is not None
+ assert "Lead" in notice.splitlines()[0]
+
+ def test_each_dimension_is_listed_on_its_own_line(self, messages: InstrumentImportMessages) -> None:
+ stated = (InstrumentOmission.CUMULATIVE_BEND, InstrumentOmission.RELEASE_POINT)
+ notice = messages.notice("Lead", stated)
+
+ assert notice is not None
+ assert [line for line in notice.splitlines() if line.startswith(OMISSION_BULLET)] == [
+ f"{OMISSION_BULLET}{messages.omissions[omission]}" for omission in stated
+ ]
+
+ def test_a_dimension_the_file_left_out_is_named_nowhere(
+ self,
+ messages: InstrumentImportMessages,
+ ) -> None:
+ notice = messages.notice("Lead", (InstrumentOmission.CUMULATIVE_BEND,))
+
+ assert notice is not None
+ assert messages.omissions[InstrumentOmission.RELEASE_POINT] not in notice
+
+ def test_a_file_stating_everything_names_everything(self, messages: InstrumentImportMessages) -> None:
+ notice = messages.notice("Lead", tuple(InstrumentOmission))
+
+ assert notice is not None
+ assert all(words in notice for words in messages.omissions.values())
+
+ def test_the_wording_holds_no_placeholder_open(self, messages: InstrumentImportMessages) -> None:
+ notice = messages.notice("Lead", (InstrumentOmission.CUMULATIVE_BEND,))
+
+ assert notice is not None
+ assert "{" not in notice
diff --git a/tests/unit/sampletones_application/config/managers/test_application.py b/tests/unit/sampletones_application/config/managers/test_application.py
index 865ae32ef..eacebedd5 100644
--- a/tests/unit/sampletones_application/config/managers/test_application.py
+++ b/tests/unit/sampletones_application/config/managers/test_application.py
@@ -132,7 +132,7 @@ def test_a_stored_scheme_stands_on_a_mac(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
- """A reader who chose the Control keys keeps them on a machine labelled Command."""
+ """A reader who chose the Control keys keeps them on a machine labeled Command."""
monkeypatch.setattr(platform, "system", lambda: "Darwin")
path = tmp_path / "config.yaml"
path.write_text(yaml.safe_dump({"shortcuts": {"scheme": DEFAULT_SCHEME_NAME}}))
diff --git a/tests/unit/sampletones_application/config/managers/test_config.py b/tests/unit/sampletones_application/config/managers/test_config.py
index 685c7b762..7cc1b37d6 100644
--- a/tests/unit/sampletones_application/config/managers/test_config.py
+++ b/tests/unit/sampletones_application/config/managers/test_config.py
@@ -17,7 +17,7 @@
LibrarySettingsUpdate,
)
from sampletones_core.configs import Config
-from sampletones_core.constants.enums import GeneratorName, SpectrumMethod
+from sampletones_core.constants.enums import ChannelName, SpectrumMethod
from sampletones_core.library import InstructionLibraryKey
@@ -154,7 +154,7 @@ def test_apply_generation_settings_updates_drive(self, tmp_path: Path) -> None:
manager = _manager(tmp_path / "missing.json")
update = GenerationSettingsUpdate(
drive=0.5,
- generators=[GeneratorName.PULSE1],
+ channels=[ChannelName.PULSE1],
)
manager.apply_generation_settings(update)
assert manager.config.generation.drive == pytest.approx(0.5)
diff --git a/tests/unit/sampletones_application/config/session/application/test_tracker.py b/tests/unit/sampletones_application/config/session/application/test_tracker.py
new file mode 100644
index 000000000..2922dfa4c
--- /dev/null
+++ b/tests/unit/sampletones_application/config/session/application/test_tracker.py
@@ -0,0 +1,20 @@
+import pytest
+from pydantic import ValidationError
+
+from sampletones_application.config.session.application.tracker import TrackerConfig
+from sampletones_application.constants.tracker import MAX_OCTAVE, MIN_OCTAVE
+
+
+class TestOctaveBounds:
+ """The typing octave is held to the range the tracker's own octave field offers."""
+
+ @pytest.mark.parametrize("octave", [MIN_OCTAVE - 1, MAX_OCTAVE + 1])
+ def test_an_octave_outside_the_range_is_rejected(self, octave: int) -> None:
+ with pytest.raises(ValidationError):
+ TrackerConfig(octave=octave)
+
+ @pytest.mark.parametrize("octave", [MIN_OCTAVE, MAX_OCTAVE])
+ def test_each_end_of_the_range_is_accepted(self, octave: int) -> None:
+ config = TrackerConfig(octave=octave)
+
+ assert config.octave == octave
diff --git a/tests/unit/sampletones_application/constants/test_keybindings.py b/tests/unit/sampletones_application/constants/test_keybindings.py
index 563dbb9d9..bbad7e46b 100644
--- a/tests/unit/sampletones_application/constants/test_keybindings.py
+++ b/tests/unit/sampletones_application/constants/test_keybindings.py
@@ -70,7 +70,7 @@ def test_a_fresh_profile_opens_on_it(
test_case: TestCase,
monkeypatch: pytest.MonkeyPatch,
) -> None:
- """A reader who has chosen nothing yet starts on the keys their machine is labelled with."""
+ """A reader who has chosen nothing yet starts on the keys their machine is labeled with."""
monkeypatch.setattr(platform, "system", lambda: test_case.system)
assert ShortcutsConfig().scheme == test_case.expected
diff --git a/tests/unit/sampletones_application/coordinators/export/__init__.py b/tests/unit/sampletones_application/coordinators/export/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/tests/unit/sampletones_application/coordinators/export/test_instrument.py b/tests/unit/sampletones_application/coordinators/export/test_instrument.py
new file mode 100644
index 000000000..49d85c0b6
--- /dev/null
+++ b/tests/unit/sampletones_application/coordinators/export/test_instrument.py
@@ -0,0 +1,198 @@
+from pathlib import Path
+from typing import Dict, Final, List, Tuple
+from unittest.mock import MagicMock
+
+import pytest
+
+from sampletones_application.categories.exports import INSTRUMENT_EXPORT_FORMATS
+from sampletones_application.categories.manager import LanguageManager
+from sampletones_application.coordinators.export import instrument as instrument_module
+from sampletones_application.coordinators.export.instrument import (
+ InstrumentExportCoordinator,
+)
+from sampletones_application.exports import build_export_backends
+from sampletones_application.logic.export.instrument.source import (
+ ExportableInstrument,
+ exportable_instrument,
+ voice_entries,
+)
+from sampletones_application.paths import LANG_EN
+from sampletones_core.constants.enums import ChannelName
+from sampletones_core.exports.request import InstrumentSource
+from sampletones_core.exports.scope import ExportScope
+from sampletones_core.project.project import Project
+from sampletones_core.project.voices.creation import new_instrument
+
+REMEMBERED_DIRECTORY: Final[Path] = Path("/instruments")
+SUGGESTED_NAME: Final[str] = "Lead"
+DESTINATION: Final[Path] = REMEMBERED_DIRECTORY / "Lead.fti"
+
+
+def _source() -> InstrumentSource:
+ voice = new_instrument(SUGGESTED_NAME)
+ project = Project.create()
+ project.voices.append(voice)
+ return exportable_instrument(project, voice_entries(voice)[0]).source
+
+
+@pytest.fixture
+def logic() -> MagicMock:
+ mock = MagicMock()
+ mock.suggested_directory = REMEMBERED_DIRECTORY
+ mock.backends = build_export_backends()
+ return mock
+
+
+@pytest.fixture
+def coordinator(logic: MagicMock) -> InstrumentExportCoordinator:
+ return InstrumentExportCoordinator(logic, LanguageManager(LANG_EN))
+
+
+@pytest.fixture
+def confirmed(monkeypatch: pytest.MonkeyPatch) -> List[Dict[str, object]]:
+ """The save dialog, answering with a destination and recording how it was opened."""
+ opened: List[Dict[str, object]] = []
+
+ def _save(**kwargs: object) -> Path:
+ opened.append(kwargs)
+ return DESTINATION
+
+ monkeypatch.setattr(instrument_module, "save_file_dialog", _save)
+ return opened
+
+
+@pytest.fixture
+def canceled(monkeypatch: pytest.MonkeyPatch) -> None:
+ monkeypatch.setattr(instrument_module, "save_file_dialog", lambda **_kwargs: None)
+
+
+class TestAskingWhereAnInstrumentGoes:
+ def test_the_dialog_opens_where_the_last_instrument_landed(
+ self,
+ coordinator: InstrumentExportCoordinator,
+ confirmed: List[Dict[str, object]],
+ ) -> None:
+ coordinator.request(_source(), SUGGESTED_NAME)
+
+ assert confirmed[0]["initial_directory"] == REMEMBERED_DIRECTORY
+
+ def test_the_instrument_names_the_file_it_is_offered_as(
+ self,
+ coordinator: InstrumentExportCoordinator,
+ confirmed: List[Dict[str, object]],
+ ) -> None:
+ coordinator.request(_source(), SUGGESTED_NAME)
+
+ assert confirmed[0]["default_filename"] == SUGGESTED_NAME
+
+ def test_every_format_writing_one_instrument_is_offered_at_once(
+ self,
+ coordinator: InstrumentExportCoordinator,
+ confirmed: List[Dict[str, object]],
+ ) -> None:
+ """Choosing the format is the dialog's own type selector, not the menu that reached it."""
+ coordinator.request(_source(), SUGGESTED_NAME)
+
+ filters = confirmed[0]["filters"]
+ assert isinstance(filters, tuple)
+ assert len(filters) == len(INSTRUMENT_EXPORT_FORMATS)
+
+ def test_each_type_carries_the_extension_its_format_writes(
+ self,
+ coordinator: InstrumentExportCoordinator,
+ logic: MagicMock,
+ confirmed: List[Dict[str, object]],
+ ) -> None:
+ coordinator.request(_source(), SUGGESTED_NAME)
+
+ filters = confirmed[0]["filters"]
+ assert isinstance(filters, tuple)
+ offered: Tuple[str, ...] = tuple(pattern for file_filter in filters for pattern in file_filter.patterns)
+ for export_format in INSTRUMENT_EXPORT_FORMATS:
+ extension = logic.backends[export_format].extension(ExportScope.INSTRUMENT)
+ assert any(pattern.endswith(extension) for pattern in offered)
+
+ def test_each_type_is_named_after_the_program_that_reads_it(
+ self,
+ coordinator: InstrumentExportCoordinator,
+ confirmed: List[Dict[str, object]],
+ ) -> None:
+ coordinator.request(_source(), SUGGESTED_NAME)
+
+ filters = confirmed[0]["filters"]
+ assert isinstance(filters, tuple)
+ assert len({file_filter.name for file_filter in filters}) == len(INSTRUMENT_EXPORT_FORMATS)
+
+
+class TestAskingForOneOfAVoicesInstruments:
+ """A voice named by a menu reaches the same dialog as a slice handed over whole."""
+
+ def test_the_voices_instrument_is_offered_under_its_own_name(
+ self,
+ coordinator: InstrumentExportCoordinator,
+ logic: MagicMock,
+ confirmed: List[Dict[str, object]],
+ ) -> None:
+ logic.voice_instrument.return_value = ExportableInstrument(name=SUGGESTED_NAME, source=_source())
+
+ coordinator.request_voice("lead-id", None)
+
+ assert confirmed[0]["default_filename"] == SUGGESTED_NAME
+
+ def test_the_channel_the_menu_named_is_the_one_asked_for(
+ self,
+ coordinator: InstrumentExportCoordinator,
+ logic: MagicMock,
+ confirmed: List[Dict[str, object]],
+ ) -> None:
+ logic.voice_instrument.return_value = ExportableInstrument(name=SUGGESTED_NAME, source=_source())
+
+ coordinator.request_voice("bass-id", ChannelName.TRIANGLE)
+
+ logic.voice_instrument.assert_called_once_with("bass-id", ChannelName.TRIANGLE)
+
+ def test_a_voice_holding_no_such_instrument_opens_nothing(
+ self,
+ coordinator: InstrumentExportCoordinator,
+ logic: MagicMock,
+ confirmed: List[Dict[str, object]],
+ ) -> None:
+ logic.voice_instrument.return_value = None
+
+ coordinator.request_voice("lead-id", None)
+
+ assert confirmed == []
+
+ def test_what_a_voice_offers_is_the_exporters_own_answer(
+ self,
+ coordinator: InstrumentExportCoordinator,
+ logic: MagicMock,
+ ) -> None:
+ """What a menu prints and what a click writes come from one place."""
+ logic.voice_instruments.return_value = (ChannelName.PULSE1, ChannelName.NOISE)
+
+ assert coordinator.voice_instruments("bass-id") == (ChannelName.PULSE1, ChannelName.NOISE)
+
+
+class TestWritingWhatWasConfirmed:
+ def test_the_destination_reaches_the_write(
+ self,
+ coordinator: InstrumentExportCoordinator,
+ logic: MagicMock,
+ confirmed: List[Dict[str, object]],
+ ) -> None:
+ source = _source()
+
+ coordinator.request(source, SUGGESTED_NAME)
+
+ logic.export.assert_called_once_with(DESTINATION, source)
+
+ def test_a_canceled_dialog_writes_nothing(
+ self,
+ coordinator: InstrumentExportCoordinator,
+ logic: MagicMock,
+ canceled: None,
+ ) -> None:
+ coordinator.request(_source(), SUGGESTED_NAME)
+
+ logic.export.assert_not_called()
diff --git a/tests/unit/sampletones_application/coordinators/tabs/test_main.py b/tests/unit/sampletones_application/coordinators/tabs/test_main.py
index c1abf3a98..92d020a78 100644
--- a/tests/unit/sampletones_application/coordinators/tabs/test_main.py
+++ b/tests/unit/sampletones_application/coordinators/tabs/test_main.py
@@ -2,11 +2,15 @@
from typing import Final
from unittest.mock import MagicMock
+import pytest
+
+from sampletones_application.constants.conversion import MAX_STEM_SOURCES
from sampletones_application.coordinators.tabs.main import MainTabCoordinator
from sampletones_application.logic.main.converter import ConversionSuccess
from sampletones_application.tags.main import (
TAG_MAIN_CONVERTER_DIALOG_CANCEL,
TAG_MAIN_CONVERTER_DIALOG_LOAD,
+ TAG_MAIN_CONVERTER_DIALOG_OVERWRITE_TARGET,
TAG_MAIN_EXPLORER_DIALOG_CONVERTER_RUNNING,
)
from tests.suite.language import FakeLanguageManager
@@ -30,6 +34,8 @@ def _coordinator(*, operation_active: bool) -> MainTabCoordinator:
coordinator._language_manager = FakeLanguageManager()
coordinator._on_reconstruct_file = MagicMock()
coordinator._on_reconstruct_directory = MagicMock()
+ coordinator._converter_logic = MagicMock()
+ coordinator._converter_logic.stems_mode = False
return coordinator
@@ -113,7 +119,7 @@ def test_file_success_refreshes_and_offers_to_load(self) -> None:
coordinator = _success_coordinator()
output_path = Path("/reconstructions/kick.rcn")
- coordinator._on_conversion_success(ConversionSuccess(is_file=True, output_path=output_path))
+ coordinator._on_conversion_success(ConversionSuccess(written=(output_path,)))
coordinator._on_refresh_trees.assert_called_once_with()
coordinator._dialogs.show_confirmation.assert_called_once()
@@ -126,10 +132,11 @@ def test_file_success_refreshes_and_offers_to_load(self) -> None:
assert kwargs["path"] == output_path
assert kwargs["on_cancel"] == coordinator._converter_logic.close
- def test_directory_success_offers_to_open_without_a_path(self) -> None:
+ def test_a_batch_offers_to_open_the_folder(self) -> None:
coordinator = _success_coordinator()
+ written = (Path("/reconstructions/kick.rcn"), Path("/reconstructions/snare.rcn"))
- coordinator._on_conversion_success(ConversionSuccess(is_file=False, output_path=Path("/reconstructions")))
+ coordinator._on_conversion_success(ConversionSuccess(written=written))
_, kwargs = coordinator._dialogs.show_confirmation.call_args
assert kwargs["ok_label"] == OPEN_BUTTON_KEY
@@ -155,3 +162,256 @@ def test_cancel_request_confirms_before_stopping(self) -> None:
assert args[3] == coordinator._converter_logic.cancel
assert kwargs["ok_label"] == STOP_BUTTON_KEY
assert kwargs["cancel_label"] == CONTINUE_BUTTON_KEY
+
+
+DISCARD_STEMS_PROMPT_KEY: Final[str] = "main.converter.message.discard_stems_prompt"
+DISCARD_STEMS_BUTTON_KEY: Final[str] = "main.converter.label.discard_stems_button"
+KEEP_STEMS_BUTTON_KEY: Final[str] = "main.converter.label.keep_stems_button"
+
+
+def _stems_coordinator(
+ *,
+ operation_active: bool = False,
+ stems_mode: bool = True,
+ source_count: int = 0,
+ room: int = MAX_STEM_SOURCES,
+) -> MainTabCoordinator:
+ coordinator = MainTabCoordinator.__new__(MainTabCoordinator)
+ coordinator._is_operation_active = lambda: operation_active
+ coordinator._notify_converter_running = lambda: operation_active
+ coordinator._dialogs = MagicMock()
+ coordinator._language_manager = FakeLanguageManager()
+ coordinator._converter_logic = MagicMock()
+ coordinator._converter_logic.stems_mode = stems_mode
+ coordinator._converter_logic.source_count = source_count
+ coordinator._converter_logic.room_for_sources = room
+ coordinator._stem_selection_window = MagicMock()
+ return coordinator
+
+
+class TestStemsModeSwitch:
+ """Leaving stems mode drops every recording but the first, so a list of several asks first."""
+
+ def test_entering_stems_mode_takes_effect_at_once(self) -> None:
+ coordinator = _stems_coordinator(stems_mode=False)
+
+ coordinator._request_stems_mode(True)
+
+ coordinator._converter_logic.set_stems_mode.assert_called_once_with(True)
+ coordinator._dialogs.show_confirmation.assert_not_called()
+
+ def test_leaving_with_one_recording_takes_effect_at_once(self) -> None:
+ coordinator = _stems_coordinator(source_count=1)
+
+ coordinator._request_stems_mode(False)
+
+ coordinator._converter_logic.set_stems_mode.assert_called_once_with(False)
+ coordinator._dialogs.show_confirmation.assert_not_called()
+
+ def test_leaving_with_several_recordings_asks_first(self) -> None:
+ coordinator = _stems_coordinator(source_count=3)
+
+ coordinator._request_stems_mode(False)
+
+ coordinator._converter_logic.set_stems_mode.assert_not_called()
+ args, kwargs = coordinator._dialogs.show_confirmation.call_args
+ assert args[1] == DISCARD_STEMS_PROMPT_KEY
+ assert kwargs["ok_label"] == DISCARD_STEMS_BUTTON_KEY
+ assert kwargs["cancel_label"] == KEEP_STEMS_BUTTON_KEY
+
+ def test_confirming_the_prompt_leaves_stems_mode(self) -> None:
+ coordinator = _stems_coordinator(source_count=3)
+
+ coordinator._request_stems_mode(False)
+ args, _ = coordinator._dialogs.show_confirmation.call_args
+ args[3]()
+
+ coordinator._converter_logic.set_stems_mode.assert_called_once_with(False)
+
+ def test_declining_the_prompt_repaints_the_checkbox(self) -> None:
+ """The checkbox already moved when it was clicked, so declining restores what stands."""
+ coordinator = _stems_coordinator(source_count=3)
+
+ coordinator._request_stems_mode(False)
+ _, kwargs = coordinator._dialogs.show_confirmation.call_args
+ kwargs["on_cancel"]()
+
+ coordinator._converter_logic.set_stems_mode.assert_not_called()
+ coordinator._converter_logic.refresh_view.assert_called_once_with()
+
+
+class TestDirectoryAdd:
+ """Ctrl-clicking a folder offers its recordings; a folder that overflows the list asks which."""
+
+ def test_a_folder_that_fits_is_added_whole(self, tmp_path: Path) -> None:
+ (tmp_path / "a.wav").touch()
+ (tmp_path / "b.wav").touch()
+ coordinator = _stems_coordinator(room=MAX_STEM_SOURCES)
+
+ coordinator._on_directory_add_requested(tmp_path)
+
+ added = coordinator._converter_logic.add_sources.call_args.args[0]
+ assert {path.name for path in added} == {"a.wav", "b.wav"}
+ coordinator._stem_selection_window.open.assert_not_called()
+
+ def test_a_folder_that_overflows_raises_the_selection(self, tmp_path: Path) -> None:
+ for index in range(3):
+ (tmp_path / f"{index}.wav").touch()
+ coordinator = _stems_coordinator(room=2)
+
+ coordinator._on_directory_add_requested(tmp_path)
+
+ coordinator._converter_logic.add_sources.assert_not_called()
+ candidates, room = coordinator._stem_selection_window.open.call_args.args
+ assert len(candidates) == 3
+ assert room == 2
+
+ def test_a_folder_holding_no_recordings_is_left_alone(self, tmp_path: Path) -> None:
+ (tmp_path / "notes.txt").write_text("not audio")
+ coordinator = _stems_coordinator()
+
+ coordinator._on_directory_add_requested(tmp_path)
+
+ coordinator._converter_logic.add_sources.assert_not_called()
+ coordinator._stem_selection_window.open.assert_not_called()
+
+ def test_a_classic_conversion_starts_gathering(self, tmp_path: Path) -> None:
+ """The gesture is what starts a stems conversion, so it turns the mode on to answer."""
+ (tmp_path / "a.wav").touch()
+ coordinator = _stems_coordinator(stems_mode=False)
+
+ coordinator._on_directory_add_requested(tmp_path)
+
+ coordinator._converter_logic.set_stems_mode.assert_called_once_with(True)
+ assert coordinator._converter_logic.add_sources.call_args.args[0][0].name == "a.wav"
+
+ def test_a_busy_application_ignores_the_gesture(self, tmp_path: Path) -> None:
+ (tmp_path / "a.wav").touch()
+ coordinator = _stems_coordinator(operation_active=True)
+
+ coordinator._on_directory_add_requested(tmp_path)
+
+ coordinator._converter_logic.add_sources.assert_not_called()
+
+
+class TestFileAdd:
+ """A recording added from the browser's menu joins a stems list, opening one where none stands."""
+
+ def test_a_recording_joins_the_list(self, tmp_path: Path) -> None:
+ recording = tmp_path / "bass.wav"
+ recording.touch()
+ coordinator = _stems_coordinator()
+
+ coordinator._on_file_add_requested(recording)
+
+ coordinator._converter_logic.add_sources.assert_called_once_with([recording])
+
+ def test_adding_from_a_classic_conversion_turns_stems_mode_on(self, tmp_path: Path) -> None:
+ recording = tmp_path / "bass.wav"
+ recording.touch()
+ coordinator = _stems_coordinator(stems_mode=False)
+
+ coordinator._on_file_add_requested(recording)
+
+ coordinator._converter_logic.set_stems_mode.assert_called_once_with(True)
+
+ def test_a_busy_application_ignores_the_gesture(self, tmp_path: Path) -> None:
+ coordinator = _stems_coordinator(operation_active=True)
+
+ coordinator._on_file_add_requested(tmp_path / "bass.wav")
+
+ coordinator._converter_logic.add_sources.assert_not_called()
+
+
+class TestModifierAddAvailability:
+ """The modifier click gathers a recording whenever the converter is free to take one."""
+
+ def test_a_gathered_list_takes_the_click(self) -> None:
+ assert _stems_coordinator()._can_add_stems() is True
+
+ def test_a_classic_conversion_takes_the_click_and_opens_a_list(self) -> None:
+ assert _stems_coordinator(stems_mode=False)._can_add_stems() is True
+
+ def test_a_busy_application_leaves_the_click_alone(self) -> None:
+ assert _stems_coordinator(operation_active=True)._can_add_stems() is False
+
+
+OVERWRITE_TARGET_PROMPT_KEY: Final[str] = "main.converter.message.overwrite_target_prompt"
+OVERWRITE_TARGET_BUTTON_KEY: Final[str] = "main.converter.label.overwrite_target_button"
+
+
+class TestOverwritePrompt:
+ """A conversion that would replace a reconstruction already made is put to the reader first."""
+
+ def test_the_prompt_names_the_file_it_would_replace(self, tmp_path: Path) -> None:
+ coordinator = _stems_coordinator()
+ target = tmp_path / "song.stn"
+
+ coordinator._confirm_overwriting_target(target)
+
+ args, kwargs = coordinator._dialogs.show_confirmation.call_args
+ assert args[0] == TAG_MAIN_CONVERTER_DIALOG_OVERWRITE_TARGET
+ assert args[1] == OVERWRITE_TARGET_PROMPT_KEY
+ assert kwargs["ok_label"] == OVERWRITE_TARGET_BUTTON_KEY
+ assert kwargs["path"] == target
+
+ def test_confirming_runs_the_conversion_it_asked_about(self, tmp_path: Path) -> None:
+ coordinator = _stems_coordinator()
+
+ coordinator._confirm_overwriting_target(tmp_path / "song.stn")
+ coordinator._dialogs.show_confirmation.call_args.args[3]()
+
+ coordinator._converter_logic.start_conversion.assert_called_once_with(confirmed=True)
+
+ def test_declining_converts_nothing(self, tmp_path: Path) -> None:
+ coordinator = _stems_coordinator()
+
+ coordinator._confirm_overwriting_target(tmp_path / "song.stn")
+
+ coordinator._converter_logic.start_conversion.assert_not_called()
+
+
+class TestReconstructLeavesStemsMode:
+ """A Reconstruct names what a classic conversion converts, so a gathered list is asked about."""
+
+ def _coordinator(self, *, stems_mode: bool) -> MainTabCoordinator:
+ coordinator = _stems_coordinator(stems_mode=stems_mode)
+ coordinator._on_reconstruct_file = MagicMock()
+ coordinator._on_reconstruct_directory = MagicMock()
+ return coordinator
+
+ def test_a_classic_conversion_reconstructs_straight_away(self, tmp_path: Path) -> None:
+ coordinator = self._coordinator(stems_mode=False)
+
+ coordinator._request_reconstruct_file(tmp_path / "a.wav")
+
+ coordinator._on_reconstruct_file.assert_called_once_with(tmp_path / "a.wav")
+ coordinator._dialogs.show_confirmation.assert_not_called()
+
+ @pytest.mark.parametrize("gesture", ["_request_reconstruct_file", "_request_reconstruct_directory"])
+ def test_a_gathered_list_is_asked_about_first(self, tmp_path: Path, gesture: str) -> None:
+ coordinator = self._coordinator(stems_mode=True)
+
+ getattr(coordinator, gesture)(tmp_path)
+
+ coordinator._on_reconstruct_file.assert_not_called()
+ coordinator._on_reconstruct_directory.assert_not_called()
+ assert coordinator._dialogs.show_confirmation.call_args.args[1] == DISCARD_STEMS_PROMPT_KEY
+
+ def test_confirming_leaves_stems_mode_and_converts(self, tmp_path: Path) -> None:
+ coordinator = self._coordinator(stems_mode=True)
+
+ coordinator._request_reconstruct_directory(tmp_path)
+ coordinator._dialogs.show_confirmation.call_args.args[3]()
+
+ coordinator._converter_logic.set_stems_mode.assert_called_once_with(False)
+ coordinator._on_reconstruct_directory.assert_called_once_with(tmp_path)
+
+ def test_declining_converts_nothing(self, tmp_path: Path) -> None:
+ coordinator = self._coordinator(stems_mode=True)
+
+ coordinator._request_reconstruct_directory(tmp_path)
+ coordinator._dialogs.show_confirmation.call_args.kwargs["on_cancel"]()
+
+ coordinator._converter_logic.set_stems_mode.assert_not_called()
+ coordinator._on_reconstruct_directory.assert_not_called()
diff --git a/tests/unit/sampletones_application/coordinators/tabs/test_reconstruction.py b/tests/unit/sampletones_application/coordinators/tabs/test_reconstruction.py
index b23318406..218ab024f 100644
--- a/tests/unit/sampletones_application/coordinators/tabs/test_reconstruction.py
+++ b/tests/unit/sampletones_application/coordinators/tabs/test_reconstruction.py
@@ -6,14 +6,16 @@
from sampletones_application.categories.export import ExportMessages
from sampletones_application.categories.manager import LanguageManager
+from sampletones_application.coordinators.tabs import reconstruction as reconstruction_module
from sampletones_application.coordinators.tabs.reconstruction import (
ReconstructionTabCoordinator,
)
from sampletones_application.paths import LANG_EN
from sampletones_application.services.export.kind import ExportKind
from sampletones_application.services.export.success import ExportSuccess
+from sampletones_core.constants.enums import ChannelName
from sampletones_core.exporters.truncation import EnvelopeTruncation
-from sampletones_core.trackers.format import TrackerFormat
+from sampletones_core.exports.format import ExportFormat
from sampletones_shared.exceptions import (
DeserializationError,
IncompatibleReconstructionVersionError,
@@ -23,6 +25,7 @@
LoadReconstructionError,
UnhandledReconstructionError,
)
+from sampletones_shared.types.callback import VoidCallback
from tests.suite.language import FakeLanguageManager
FILE_NOT_FOUND_KEY: Final[str] = "reconstructions.browser.message.file_not_found"
@@ -248,8 +251,18 @@ def test_request_remove_directory_prompts_with_path(
@pytest.fixture
-def export_coordinator() -> ReconstructionTabCoordinator:
- """A coordinator with only the collaborators ``_on_export_result`` touches."""
+def export_coordinator(monkeypatch: pytest.MonkeyPatch) -> ReconstructionTabCoordinator:
+ """A coordinator with only the collaborators ``_on_export_result`` touches.
+
+ A report waits for the frame the export window leaves the screen in, so the wait is run
+ through at once and what the coordinator reports stays observable from the call that asks.
+ """
+
+ def run_now(callback: VoidCallback, frame_count: int = 1) -> None:
+ callback()
+
+ monkeypatch.setattr(reconstruction_module.FrameCallbackManager, "set_frame_callback", run_now)
+
instance = object.__new__(ReconstructionTabCoordinator)
instance._dialogs = MagicMock()
instance._export_messages = ExportMessages.build(LanguageManager(LANG_EN))
@@ -261,6 +274,52 @@ def _shown_message(coordinator: ReconstructionTabCoordinator) -> str:
return message
+class TestExportingTheInstrumentInFront:
+ """Whatever the tab holds reaches a file the same way, so a pool voice is written by voice."""
+
+ @staticmethod
+ def _coordinator(
+ instrument: object,
+ exportable: object,
+ ) -> ReconstructionTabCoordinator:
+ instance = object.__new__(ReconstructionTabCoordinator)
+ instance._instrument_editor = MagicMock()
+ instance._instrument_editor.instrument = instrument
+ instance._instrument_exports = MagicMock()
+ instance._reconstruction_panel_logic = MagicMock()
+ instance._reconstruction_panel_logic.exportable_instrument.return_value = exportable
+ return instance
+
+ def test_a_hand_written_voice_is_written_by_the_voice_it_is(self) -> None:
+ """The sequencer's menu and this button name the same voice, so they write the same file."""
+ instrument = MagicMock()
+ instrument.id = "lead-id"
+ coordinator = self._coordinator(instrument, MagicMock())
+
+ coordinator._export_instrument(ChannelName.PULSE1)
+
+ coordinator._instrument_exports.request_voice.assert_called_once_with("lead-id", None)
+ coordinator._reconstruction_panel_logic.exportable_instrument.assert_not_called()
+
+ def test_a_reconstructions_slice_is_written_as_the_tab_holds_it(self) -> None:
+ exportable = MagicMock()
+ coordinator = self._coordinator(None, exportable)
+
+ coordinator._export_instrument(ChannelName.TRIANGLE)
+
+ coordinator._instrument_exports.request.assert_called_once_with(
+ exportable.source,
+ exportable.name,
+ )
+
+ def test_a_channel_describing_no_frame_is_written_nowhere(self) -> None:
+ coordinator = self._coordinator(None, None)
+
+ coordinator._export_instrument(ChannelName.NOISE)
+
+ coordinator._instrument_exports.request.assert_not_called()
+
+
class TestExportResultReportsTruncation:
def test_a_complete_instrument_export_shows_the_success_message(
self,
@@ -270,7 +329,7 @@ def test_a_complete_instrument_export_shows_the_success_message(
ExportSuccess(
kind=ExportKind.INSTRUMENT,
filepath=Path("lead.fti"),
- tracker_format=TrackerFormat.FAMITRACKER,
+ export_format=ExportFormat.FAMITRACKER,
truncation=None,
)
)
@@ -285,7 +344,7 @@ def test_a_shortened_instrument_export_names_both_frame_counts(
ExportSuccess(
kind=ExportKind.INSTRUMENT,
filepath=Path("lead.fti"),
- tracker_format=TrackerFormat.FAMITRACKER,
+ export_format=ExportFormat.FAMITRACKER,
truncation=EnvelopeTruncation(frames=252, source_frames=300, instruments=1),
)
)
@@ -303,7 +362,7 @@ def test_a_shortened_reconstruction_export_counts_the_instruments(
ExportSuccess(
kind=ExportKind.SAMPLE,
filepath=Path("instruments"),
- tracker_format=TrackerFormat.FAMITRACKER,
+ export_format=ExportFormat.FAMITRACKER,
truncation=EnvelopeTruncation(
frames=252,
source_frames=410,
@@ -324,7 +383,7 @@ def test_a_wav_export_shows_its_own_message(
ExportSuccess(
kind=ExportKind.WAV,
filepath=Path("track.wav"),
- tracker_format=None,
+ export_format=None,
truncation=None,
)
)
diff --git a/tests/unit/sampletones_application/coordinators/tabs/test_sequencer.py b/tests/unit/sampletones_application/coordinators/tabs/test_sequencer.py
index b0e7d7783..41b9c095e 100644
--- a/tests/unit/sampletones_application/coordinators/tabs/test_sequencer.py
+++ b/tests/unit/sampletones_application/coordinators/tabs/test_sequencer.py
@@ -6,11 +6,20 @@
import pytest
from sampletones_application.categories.hierarchy import Tab
+from sampletones_application.categories.instrument import InstrumentImportMessages
from sampletones_application.categories.manager import LanguageManager
from sampletones_application.constants.playback import FollowMode
from sampletones_application.constants.sequencer import CHANNEL_AXIS
from sampletones_application.coordinators.playback.guard import GuardedPlayer
-from sampletones_application.coordinators.tabs.sequencer import SequencerTabCoordinator
+from sampletones_application.coordinators.tabs.sequencer import voices as voices_module
+from sampletones_application.coordinators.tabs.sequencer.blocks import SequencerBlocks
+from sampletones_application.coordinators.tabs.sequencer.coordinator import SequencerTabCoordinator
+from sampletones_application.coordinators.tabs.sequencer.frames import SequencerFrames
+from sampletones_application.coordinators.tabs.sequencer.history import SequencerHistoryRecorder
+from sampletones_application.coordinators.tabs.sequencer.playhead import SequencerPlayhead
+from sampletones_application.coordinators.tabs.sequencer.project import OpenProjectRequirement
+from sampletones_application.coordinators.tabs.sequencer.reconstructions import SequencerReconstructions
+from sampletones_application.coordinators.tabs.sequencer.voices import SequencerVoices
from sampletones_application.logic.history.action import HistoryAction
from sampletones_application.logic.history.manager import HistoryManager
from sampletones_application.logic.history.snapshot import HistoryEntry
@@ -20,30 +29,21 @@
ALL_CHANNELS,
SequencerChannelsLogic,
)
-from sampletones_application.logic.sequencer.clipboard import (
- OrderBlockText,
- ParsedBlockCache,
- ProjectSampleDirectory,
- SequencerClipboard,
- TrackerBlockText,
+from sampletones_application.logic.sequencer.history_detail import (
+ SequencerHistoryDetail,
)
-from sampletones_application.logic.sequencer.history_detail import SequencerHistoryDetail
from sampletones_application.logic.sequencer.order import (
- OrderBlockReader,
- OrderBlockWriter,
SequencerOrderLogic,
)
from sampletones_application.logic.sequencer.tracker import (
SequencerTrackerLogic,
- TrackerBlockReader,
- TrackerBlockWriter,
)
from sampletones_application.logic.shared.project_source import snapshot_project
from sampletones_application.paths import LANG_EN
from sampletones_application.ui.panels.sequencer import channels as channels_module
-from sampletones_application.ui.panels.sequencer import tracker as tracker_module
-from sampletones_application.ui.panels.sequencer.order import GUISequencerOrderPanel
-from sampletones_application.ui.panels.sequencer.tracker import GUISequencerTrackerPanel
+from sampletones_application.ui.panels.sequencer.order.panel import GUISequencerOrderPanel
+from sampletones_application.ui.panels.sequencer.tracker import panel as tracker_module
+from sampletones_application.ui.panels.sequencer.tracker.panel import GUISequencerTrackerPanel
from sampletones_application.utils.gui.keyboard.modifiers import CTRL, NO_MODIFIERS
from sampletones_application.view_model.sequencer.region import (
OrderCell,
@@ -51,116 +51,411 @@
TrackerCell,
TrackerRegion,
)
-from sampletones_application.view_model.sequencer.samples import SampleSelection
from sampletones_application.view_model.sequencer.slot import TrackerSlot
from sampletones_application.view_model.sequencer.song_player import SongPlayerViewModel
from sampletones_application.view_model.sequencer.subcolumn import SubColumn
+from sampletones_application.view_model.sequencer.voices import (
+ VoiceKind,
+ VoiceSelection,
+)
from sampletones_application.view_model.shared.history import (
HistoryDetailRole,
HistoryDetailSegment,
- HistoryDetailWord,
- HistoryDetailWordSegment,
)
-from sampletones_core.constants.enums import GeneratorName
+from sampletones_core.constants.enums import ChannelName
+from sampletones_core.features.envelope import Envelope
+from sampletones_core.formats.famitracker.voice import ImportedVoice, InstrumentOmission
from sampletones_core.project.song_position import SongPosition
-from sampletones_shared.exceptions import InvalidReconstructionValuesError
+from sampletones_core.project.voices.envelopes import InstrumentEnvelopes
+from sampletones_core.project.voices.instrument import Instrument
+from sampletones_shared.exceptions import (
+ InvalidReconstructionValuesError,
+ MalformedInstrumentError,
+)
from tests.suite.language import FakeLanguageManager
FREQUENCY_MISMATCH_MESSAGE_KEY: Final[str] = "global.dialog.message.frequency_mismatch"
-REMOVE_SAMPLE_MESSAGE_KEY: Final[str] = "global.dialog.message.remove_sample"
+REMOVE_VOICE_MESSAGE_KEY: Final[str] = "global.dialog.message.remove_voice"
TEXTS: Final[Dict[str, str]] = {
FREQUENCY_MISMATCH_MESSAGE_KEY: "recon {reconstruction} vs project {project}",
- REMOVE_SAMPLE_MESSAGE_KEY: "Remove {name}?",
+ REMOVE_VOICE_MESSAGE_KEY: "Remove {name}?",
}
+def _reconstructions(
+ browser_logic: MagicMock,
+ tracker_logic: MagicMock,
+ project_controller: MagicMock,
+ dialogs: MagicMock,
+ voices_panel: MagicMock,
+ on_tab_switch: MagicMock,
+) -> SequencerReconstructions:
+ """The reconstruction gestures over the collaborators a test states, with the rest mocked."""
+ language_manager = FakeLanguageManager(TEXTS)
+ return SequencerReconstructions(
+ browser_logic,
+ tracker_logic,
+ MagicMock(),
+ voices_panel,
+ MagicMock(),
+ MagicMock(),
+ project_controller,
+ OpenProjectRequirement(
+ project_controller,
+ dialogs=dialogs,
+ language_manager=language_manager,
+ ),
+ dialogs=dialogs,
+ language_manager=language_manager,
+ on_tab_switch=on_tab_switch,
+ on_sample_reconstruction_replaced=MagicMock(),
+ )
+
+
@pytest.fixture
-def coordinator() -> SequencerTabCoordinator:
- """A coordinator with only the collaborators ``import_reconstruction`` touches.
+def coordinator() -> SequencerReconstructions:
+ """The reconstruction gestures with only the collaborators an import touches.
- The full constructor builds the sequencer's GUI subtree (themes, fonts, synthesiser), which
- is out of scope here; only the import orchestration is under test. Defaults to an open project
- with samples and a matching reconstruction frequency (60 Hz); individual tests override.
+ The tab's full constructor builds the sequencer's GUI subtree (themes, fonts, synthesiser),
+ which is out of scope here; only the import orchestration is under test. Defaults to an open
+ project with samples and a matching reconstruction frequency (60 Hz); individual tests
+ override.
"""
- instance = object.__new__(SequencerTabCoordinator)
- instance._history = MagicMock()
- instance._history_detail = MagicMock()
- instance._project_controller = MagicMock()
- instance._project_controller.is_open = True
- instance._project_controller.has_samples = True
- instance._sequencer_browser_logic = MagicMock()
- instance._sequencer_browser_logic.load_reconstruction.return_value.config.nes_frequency = 60
- instance._sequencer_tracker_logic = MagicMock()
- instance._sequencer_tracker_logic.settings.nes_frequency = 60
- instance._dialogs = MagicMock()
- instance._on_tab_switch = MagicMock()
- instance._language_manager = FakeLanguageManager(TEXTS)
- instance._msg_no_project = "no project"
- instance._ttl_no_project = "No project open"
- return instance
+ browser_logic = MagicMock()
+ browser_logic.load_reconstruction.return_value.config.nes_frequency = 60
+ tracker_logic = MagicMock()
+ tracker_logic.settings.nes_frequency = 60
+ project_controller = MagicMock()
+ project_controller.is_open = True
+ project_controller.has_voices = True
+ return _reconstructions(
+ browser_logic,
+ tracker_logic,
+ project_controller,
+ MagicMock(),
+ MagicMock(),
+ MagicMock(),
+ )
+
+
+INSTRUMENT_FILE: Final[Path] = Path("/instruments/Lead.fti")
+
+IMPORTED_VOICE: Final[Instrument] = Instrument(
+ name="Lead",
+ envelopes=InstrumentEnvelopes(volume=Envelope(items=(15, 8, 0), loop_point=0)),
+)
+
+
+def _imported(*omissions: InstrumentOmission) -> ImportedVoice:
+ return ImportedVoice(voice=IMPORTED_VOICE, omissions=omissions)
+
+
+def _voices(
+ voices_logic: MagicMock,
+ project_controller: MagicMock,
+ session_manager: MagicMock,
+ dialogs: MagicMock,
+) -> SequencerVoices:
+ """The pool gestures over the collaborators a test states, with the rest mocked."""
+ language_manager = FakeLanguageManager(TEXTS)
+ return SequencerVoices(
+ voices_logic,
+ MagicMock(),
+ MagicMock(),
+ project_controller,
+ session_manager,
+ OpenProjectRequirement(
+ project_controller,
+ dialogs=dialogs,
+ language_manager=language_manager,
+ ),
+ dialogs=dialogs,
+ language_manager=language_manager,
+ import_messages=InstrumentImportMessages.build(LanguageManager(LANG_EN)),
+ import_reconstruction=MagicMock(),
+ )
@pytest.fixture
-def samples_coordinator() -> SequencerTabCoordinator:
- """A coordinator with only the collaborators the samples-menu handlers touch."""
- instance = object.__new__(SequencerTabCoordinator)
- instance._history = MagicMock()
- instance._history_detail = MagicMock()
- instance._sequencer_samples_logic = MagicMock()
- instance._dialogs = MagicMock()
- instance._language_manager = FakeLanguageManager(TEXTS)
- return instance
+def instrument_voices() -> SequencerVoices:
+ """The pool gestures with only the collaborators ``import_instrument`` touches."""
+ voices_logic = MagicMock()
+ voices_logic.read_instrument.return_value = _imported()
+ project_controller = MagicMock()
+ project_controller.is_open = True
+ session_manager = MagicMock()
+ session_manager.get_instrument_path.return_value = INSTRUMENT_FILE.parent
+ return _voices(voices_logic, project_controller, session_manager, MagicMock())
+
+
+@pytest.fixture
+def located_file(monkeypatch: pytest.MonkeyPatch) -> List[Dict[str, object]]:
+ """The file dialog, answering with an instrument file and recording how it was opened."""
+ opened: List[Dict[str, object]] = []
+
+ def _open(**kwargs: object) -> Path:
+ opened.append(kwargs)
+ return INSTRUMENT_FILE
+
+ monkeypatch.setattr(voices_module, "open_file_dialog", _open)
+ return opened
+
+
+@pytest.fixture
+def canceled_dialog(monkeypatch: pytest.MonkeyPatch) -> None:
+ monkeypatch.setattr(voices_module, "open_file_dialog", lambda **_kwargs: None)
+
+
+class TestImportInstrument:
+ """A FamiTracker instrument file arrives as a voice, and says what it held past one."""
+
+ def test_a_project_is_asked_for_before_a_file_is(
+ self,
+ instrument_voices: SequencerVoices,
+ located_file: List[Dict[str, object]],
+ ) -> None:
+ """A voice needs a pool to land in, so a closed project stops the gesture at the door."""
+ instrument_voices._project_controller.is_open = False
+
+ instrument_voices.import_instrument()
+
+ assert located_file == []
+ instrument_voices._dialogs.show_info.assert_called_once()
+
+ def test_the_dialog_opens_where_the_last_instrument_was(
+ self,
+ instrument_voices: SequencerVoices,
+ located_file: List[Dict[str, object]],
+ ) -> None:
+ instrument_voices.import_instrument()
+
+ assert located_file[0]["initial_directory"] == INSTRUMENT_FILE.parent
+
+ def test_the_folder_the_file_came_from_is_remembered(
+ self,
+ instrument_voices: SequencerVoices,
+ located_file: List[Dict[str, object]],
+ ) -> None:
+ instrument_voices.import_instrument()
+
+ instrument_voices._session_manager.set_instrument_path.assert_called_once_with(INSTRUMENT_FILE.parent)
+
+ def test_a_canceled_dialog_leaves_the_pool_as_it_stands(
+ self,
+ instrument_voices: SequencerVoices,
+ canceled_dialog: None,
+ ) -> None:
+ instrument_voices.import_instrument()
+
+ instrument_voices._voices_logic.read_instrument.assert_not_called()
+ instrument_voices._voices_logic.add_instrument.assert_not_called()
+
+ def test_the_voice_the_file_made_joins_the_pool(
+ self,
+ instrument_voices: SequencerVoices,
+ located_file: List[Dict[str, object]],
+ ) -> None:
+ instrument_voices.import_instrument()
+
+ logic = instrument_voices._voices_logic
+ logic.read_instrument.assert_called_once_with(INSTRUMENT_FILE)
+ logic.add_instrument.assert_called_once_with(IMPORTED_VOICE)
+
+ def test_the_whole_gesture_is_one_history_entry(
+ self,
+ instrument_voices: SequencerVoices,
+ located_file: List[Dict[str, object]],
+ ) -> None:
+ instrument_voices.import_instrument()
+
+ action = instrument_voices._history.transaction.call_args.args[0]
+ assert action is HistoryAction.ADD_INSTRUMENT
+ instrument_voices._history_detail.add_instrument.assert_called_once_with(IMPORTED_VOICE.name)
+
+ def test_what_the_file_held_past_the_voice_is_reported(
+ self,
+ instrument_voices: SequencerVoices,
+ located_file: List[Dict[str, object]],
+ ) -> None:
+ logic = instrument_voices._voices_logic
+ logic.read_instrument.return_value = _imported(InstrumentOmission.CUMULATIVE_BEND)
+
+ instrument_voices.import_instrument()
+
+ notice = instrument_voices._dialogs.show_info.call_args.args[1]
+ assert instrument_voices._import_messages.omissions[InstrumentOmission.CUMULATIVE_BEND] in notice
+
+ def test_a_file_holding_the_voice_alone_is_reported_nowhere(
+ self,
+ instrument_voices: SequencerVoices,
+ located_file: List[Dict[str, object]],
+ ) -> None:
+ """An import that lost nothing interrupts the reader with nothing."""
+ instrument_voices.import_instrument()
+
+ instrument_voices._dialogs.show_info.assert_not_called()
+
+ def test_a_file_that_is_not_there_is_reported_as_missing(
+ self,
+ instrument_voices: SequencerVoices,
+ located_file: List[Dict[str, object]],
+ ) -> None:
+ logic = instrument_voices._voices_logic
+ logic.read_instrument.side_effect = FileNotFoundError(INSTRUMENT_FILE)
+
+ instrument_voices.import_instrument()
+
+ instrument_voices._dialogs.show_file_not_found.assert_called_once()
+ logic.add_instrument.assert_not_called()
+
+ def test_a_file_the_reader_cannot_take_is_reported(
+ self,
+ instrument_voices: SequencerVoices,
+ located_file: List[Dict[str, object]],
+ ) -> None:
+ logic = instrument_voices._voices_logic
+ logic.read_instrument.side_effect = MalformedInstrumentError("truncated")
+
+ instrument_voices.import_instrument()
+
+ instrument_voices._dialogs.show_error.assert_called_once()
+ logic.add_instrument.assert_not_called()
+
+ def test_a_file_the_reader_cannot_take_records_no_history(
+ self,
+ instrument_voices: SequencerVoices,
+ located_file: List[Dict[str, object]],
+ ) -> None:
+ """The file is read before the pool is touched, so a refusal leaves the project as it was."""
+ logic = instrument_voices._voices_logic
+ logic.read_instrument.side_effect = MalformedInstrumentError("truncated")
+
+ instrument_voices.import_instrument()
+
+ instrument_voices._history.transaction.assert_not_called()
+
+
+@pytest.fixture
+def samples_voices() -> SequencerVoices:
+ """The pool gestures with only the collaborators the samples-menu handlers touch."""
+ return _voices(MagicMock(), MagicMock(), MagicMock(), MagicMock())
class TestRemoveSample:
def test_unused_sample_is_removed_without_confirmation(
self,
- samples_coordinator: SequencerTabCoordinator,
+ samples_voices: SequencerVoices,
) -> None:
- samples_coordinator._sequencer_samples_logic.is_sample_used.return_value = False
+ samples_voices._voices_logic.is_voice_used.return_value = False
- samples_coordinator._remove_sample("abc")
+ samples_voices.remove("abc")
- samples_coordinator._sequencer_samples_logic.remove_sample.assert_called_once_with("abc")
- samples_coordinator._dialogs.show_confirmation.assert_not_called()
+ samples_voices._voices_logic.remove_voice.assert_called_once_with("abc")
+ samples_voices._dialogs.show_confirmation.assert_not_called()
def test_used_sample_prompts_confirmation_before_removing(
self,
- samples_coordinator: SequencerTabCoordinator,
+ samples_voices: SequencerVoices,
) -> None:
- logic = samples_coordinator._sequencer_samples_logic
- logic.is_sample_used.return_value = True
- logic.sample_name.return_value = "lead"
+ logic = samples_voices._voices_logic
+ logic.is_voice_used.return_value = True
+ logic.voice_name.return_value = "lead"
- samples_coordinator._remove_sample("abc")
+ samples_voices.remove("abc")
- samples_coordinator._dialogs.show_confirmation.assert_called_once()
- logic.remove_sample.assert_not_called()
+ samples_voices._dialogs.show_confirmation.assert_called_once()
+ logic.remove_voice.assert_not_called()
- confirmation = samples_coordinator._dialogs.show_confirmation.call_args.kwargs
+ confirmation = samples_voices._dialogs.show_confirmation.call_args.kwargs
assert confirmation["message"] == "Remove lead?"
confirmation["on_confirm"]()
- logic.remove_sample.assert_called_once_with("abc")
+ logic.remove_voice.assert_called_once_with("abc")
+
+
+class TestTakingAChannelAsAnInstrument:
+ """A channel of a recording becomes a voice of its own, recorded and brought up to edit."""
+
+ @staticmethod
+ def _taken(samples_voices: SequencerVoices) -> Instrument:
+ instrument = Instrument(name="Bass (triangle)", envelopes=InstrumentEnvelopes(volume=Envelope(items=(15,))))
+ samples_voices._voices_logic.instrument_from_channel.return_value = instrument
+ return instrument
+
+ def test_the_channel_named_is_the_one_taken(
+ self,
+ samples_voices: SequencerVoices,
+ ) -> None:
+ self._taken(samples_voices)
+
+ samples_voices.add_instrument_from_channel("bass-id", ChannelName.TRIANGLE)
+
+ samples_voices._voices_logic.instrument_from_channel.assert_called_once_with(
+ "bass-id",
+ ChannelName.TRIANGLE,
+ )
+
+ def test_the_new_voice_lands_in_the_pool(
+ self,
+ samples_voices: SequencerVoices,
+ ) -> None:
+ instrument = self._taken(samples_voices)
+
+ samples_voices.add_instrument_from_channel("bass-id", ChannelName.TRIANGLE)
+
+ samples_voices._voices_logic.add_instrument.assert_called_once_with(instrument)
+
+ def test_the_history_names_the_voice_that_arrived(
+ self,
+ samples_voices: SequencerVoices,
+ ) -> None:
+ instrument = self._taken(samples_voices)
+
+ samples_voices.add_instrument_from_channel("bass-id", ChannelName.TRIANGLE)
+
+ samples_voices._history_detail.add_instrument.assert_called_once_with(instrument.name)
+ assert samples_voices._history.transaction.call_args.args[0] is HistoryAction.ADD_INSTRUMENT
+
+ def test_the_new_voice_is_brought_up_where_it_is_edited(
+ self,
+ samples_voices: SequencerVoices,
+ ) -> None:
+ """Seeing the envelopes that came across is what taking the channel out was for."""
+ instrument = self._taken(samples_voices)
+
+ samples_voices.add_instrument_from_channel("bass-id", ChannelName.TRIANGLE)
+
+ samples_voices._voices_logic.request_edit.assert_called_once_with(instrument.id)
+
+ def test_a_channel_that_plays_nothing_leaves_the_pool_as_it_stands(
+ self,
+ samples_voices: SequencerVoices,
+ ) -> None:
+ samples_voices._voices_logic.instrument_from_channel.return_value = None
+
+ samples_voices.add_instrument_from_channel("bass-id", ChannelName.NOISE)
+
+ samples_voices._voices_logic.add_instrument.assert_not_called()
+ samples_voices._history.transaction.assert_not_called()
class TestSubmitRename:
def test_submit_rename_trims_whitespace(
self,
- samples_coordinator: SequencerTabCoordinator,
+ samples_voices: SequencerVoices,
) -> None:
- samples_coordinator._submit_rename("abc", " bass ")
+ samples_voices.submit_rename("abc", " bass ")
- samples_coordinator._sequencer_samples_logic.rename_sample.assert_called_once_with("abc", "bass")
+ samples_voices._voices_logic.rename_voice.assert_called_once_with("abc", "bass")
def test_submit_rename_ignores_blank_name(
self,
- samples_coordinator: SequencerTabCoordinator,
+ samples_voices: SequencerVoices,
) -> None:
- samples_coordinator._submit_rename("abc", " ")
+ samples_voices.submit_rename("abc", " ")
- samples_coordinator._sequencer_samples_logic.rename_sample.assert_not_called()
+ samples_voices._voices_logic.rename_voice.assert_not_called()
@pytest.fixture
@@ -172,7 +467,7 @@ def nes_frequency_coordinator() -> SequencerTabCoordinator:
instance._sequencer_tracker_logic = MagicMock()
instance._sequencer_tracker_logic.settings.nes_frequency = 60
instance._project_controller = MagicMock()
- instance._project_controller.has_samples = True
+ instance._project_controller.has_voices = True
instance._dialogs = MagicMock()
instance._on_nes_frequency_changed = MagicMock()
instance._nes_frequency_change_acknowledged = False
@@ -194,7 +489,7 @@ def test_applies_without_confirmation_when_no_samples(
self,
nes_frequency_coordinator: SequencerTabCoordinator,
) -> None:
- nes_frequency_coordinator._project_controller.has_samples = False
+ nes_frequency_coordinator._project_controller.has_voices = False
nes_frequency_coordinator._request_nes_frequency_change(30)
@@ -268,7 +563,7 @@ def _playhead(frame_index: int, row_index: int) -> SongPosition:
def _player_view(*, follow_mode: FollowMode) -> SongPlayerViewModel:
- """A stopped transport view, which is what the coordinator reads the follow behaviour from."""
+ """A stopped transport view, which is what the coordinator reads the follow behavior from."""
return SongPlayerViewModel(
is_loaded=True,
is_playing=False,
@@ -288,7 +583,17 @@ def playback_coordinator() -> SequencerTabCoordinator:
instance._sequencer_tracker_logic = MagicMock()
instance._sequencer_tracker_panel = MagicMock()
instance._sequencer_order_panel = MagicMock()
- instance._playing_position = None
+ instance._playhead = SequencerPlayhead(
+ instance._sequencer_tracker_panel,
+ instance._sequencer_order_panel,
+ )
+ instance._frames = SequencerFrames(
+ MagicMock(),
+ instance._sequencer_tracker_logic,
+ instance._song_player_logic,
+ MagicMock(),
+ instance._playhead,
+ )
return instance
@@ -353,7 +658,7 @@ def test_order_selection_seeks_the_playhead_while_following(
"""Choosing a frame always picks what is edited, and moves the playhead when following."""
playback_coordinator._song_player_logic.follow_mode = mode
- playback_coordinator._on_order_frame_selected(3)
+ playback_coordinator._frames.select(3)
playback_coordinator._sequencer_tracker_logic.select_frame.assert_called_once_with(3)
assert playback_coordinator._song_player_logic.seek.called is mode.follows_pattern
@@ -370,326 +675,323 @@ def test_a_chosen_mode_reaches_the_player(
@pytest.fixture
-def order_ops_coordinator() -> SequencerTabCoordinator:
- """A coordinator with only the collaborators the order-frame handlers touch."""
- instance = object.__new__(SequencerTabCoordinator)
- instance._sequencer_order_logic = MagicMock()
- instance._sequencer_tracker_logic = MagicMock()
- instance._sequencer_order_panel = MagicMock()
- instance._sequencer_tracker_panel = MagicMock()
- instance._song_player_logic = MagicMock()
- instance._project_controller = MagicMock()
- instance._playing_position = None
- return instance
+def order_ops_frames() -> SequencerFrames:
+ """The frame gestures over mocked collaborators, behind a playhead marking mocked grids."""
+ return SequencerFrames(
+ MagicMock(),
+ MagicMock(),
+ MagicMock(),
+ MagicMock(),
+ SequencerPlayhead(MagicMock(), MagicMock()),
+ )
class TestOrderFrameOperations:
def test_insert_adds_a_frame_after_the_target(
self,
- order_ops_coordinator: SequencerTabCoordinator,
+ order_ops_frames: SequencerFrames,
) -> None:
- coordinator = order_ops_coordinator
- coordinator._song_player_logic.is_playing.return_value = False
+ frames = order_ops_frames
+ frames._song_player_logic.is_playing.return_value = False
- coordinator._on_order_insert(2)
+ frames.insert(2)
- coordinator._sequencer_order_logic.insert_frame.assert_called_once_with(3)
+ frames._order_logic.insert_frame.assert_called_once_with(3)
def test_remove_pulls_playhead_earlier_when_playing(
self,
- order_ops_coordinator: SequencerTabCoordinator,
+ order_ops_frames: SequencerFrames,
) -> None:
- coordinator = order_ops_coordinator
- coordinator._playing_position = _playhead(3, SOUNDING_ROW)
- coordinator._project_controller.order_length = 5
+ frames = order_ops_frames
+ frames._playhead.stand_at(3, SOUNDING_ROW)
+ frames._project_controller.order_length = 5
- coordinator._on_order_remove(1)
+ frames.remove(1)
- coordinator._sequencer_order_logic.remove_from_order.assert_called_once_with(1)
- coordinator._song_player_logic.relocate.assert_called_once_with(2)
+ frames._order_logic.remove_from_order.assert_called_once_with(1)
+ frames._song_player_logic.relocate.assert_called_once_with(2)
def test_remove_does_not_relocate_when_not_playing(
self,
- order_ops_coordinator: SequencerTabCoordinator,
+ order_ops_frames: SequencerFrames,
) -> None:
- coordinator = order_ops_coordinator
- coordinator._playing_position = None
- coordinator._project_controller.order_length = 5
+ frames = order_ops_frames
+ frames._playhead.stop()
+ frames._project_controller.order_length = 5
- coordinator._on_order_remove(1)
+ frames.remove(1)
- coordinator._song_player_logic.relocate.assert_not_called()
+ frames._song_player_logic.relocate.assert_not_called()
def test_duplicate_before_playhead_shifts_it_later(
self,
- order_ops_coordinator: SequencerTabCoordinator,
+ order_ops_frames: SequencerFrames,
) -> None:
- coordinator = order_ops_coordinator
- coordinator._playing_position = _playhead(2, SOUNDING_ROW)
- coordinator._song_player_logic.is_playing.return_value = True
+ frames = order_ops_frames
+ frames._playhead.stand_at(2, SOUNDING_ROW)
+ frames._song_player_logic.is_playing.return_value = True
- coordinator._on_order_duplicate(0)
+ frames.duplicate(0)
- coordinator._sequencer_order_logic.duplicate_frame.assert_called_once_with(0)
- coordinator._song_player_logic.relocate.assert_called_once_with(3)
+ frames._order_logic.duplicate_frame.assert_called_once_with(0)
+ frames._song_player_logic.relocate.assert_called_once_with(3)
def test_move_makes_the_playing_frame_follow_itself(
self,
- order_ops_coordinator: SequencerTabCoordinator,
+ order_ops_frames: SequencerFrames,
) -> None:
- coordinator = order_ops_coordinator
- coordinator._playing_position = _playhead(2, SOUNDING_ROW)
- coordinator._song_player_logic.is_playing.return_value = True
+ frames = order_ops_frames
+ frames._playhead.stand_at(2, SOUNDING_ROW)
+ frames._song_player_logic.is_playing.return_value = True
- coordinator._on_order_move(2, 5)
+ frames.move(2, 5)
- coordinator._sequencer_order_logic.move_frame.assert_called_once_with(2, 5)
- coordinator._song_player_logic.relocate.assert_called_once_with(5)
+ frames._order_logic.move_frame.assert_called_once_with(2, 5)
+ frames._song_player_logic.relocate.assert_called_once_with(5)
def test_move_advances_cursor_and_highlight_immediately(
self,
- order_ops_coordinator: SequencerTabCoordinator,
+ order_ops_frames: SequencerFrames,
) -> None:
# The cursor and playing highlight must advance on the keypress, not on the next row
# update, so a rapid second Alt+arrow acts on the moved frame rather than snapping back.
- coordinator = order_ops_coordinator
- coordinator._playing_position = _playhead(2, SOUNDING_ROW)
- coordinator._song_player_logic.is_playing.return_value = True
+ frames = order_ops_frames
+ frames._playhead.stand_at(2, SOUNDING_ROW)
+ frames._song_player_logic.is_playing.return_value = True
- coordinator._on_order_move(2, 3)
+ frames.move(2, 3)
- coordinator._sequencer_tracker_logic.select_frame.assert_called_once_with(3)
- coordinator._sequencer_order_panel.set_playing_position.assert_called_once_with(3)
+ frames._tracker_logic.select_frame.assert_called_once_with(3)
+ frames._playhead._order_panel.set_playing_position.assert_called_once_with(3)
def test_move_carries_the_sounding_row_to_the_frame_it_lands_on(
self,
- order_ops_coordinator: SequencerTabCoordinator,
+ order_ops_frames: SequencerFrames,
) -> None:
"""The tracker's mark belongs to a frame, so an edit that moves the frame moves the mark."""
- coordinator = order_ops_coordinator
- coordinator._playing_position = _playhead(2, SOUNDING_ROW)
- coordinator._song_player_logic.is_playing.return_value = True
+ frames = order_ops_frames
+ frames._playhead.stand_at(2, SOUNDING_ROW)
+ frames._song_player_logic.is_playing.return_value = True
- coordinator._on_order_move(2, 5)
+ frames.move(2, 5)
- panel = coordinator._sequencer_tracker_panel
+ panel = frames._playhead._tracker_panel
panel.set_playing_position.assert_called_once_with(_playhead(5, SOUNDING_ROW))
def test_clear_leaves_the_playhead_in_place(
self,
- order_ops_coordinator: SequencerTabCoordinator,
+ order_ops_frames: SequencerFrames,
) -> None:
- coordinator = order_ops_coordinator
- coordinator._playing_position = _playhead(2, SOUNDING_ROW)
+ frames = order_ops_frames
+ frames._playhead.stand_at(2, SOUNDING_ROW)
- coordinator._on_order_clear(2)
+ frames.clear(2)
- coordinator._sequencer_order_logic.clear_frame.assert_called_once_with(2)
- coordinator._song_player_logic.relocate.assert_not_called()
+ frames._order_logic.clear_frame.assert_called_once_with(2)
+ frames._song_player_logic.relocate.assert_not_called()
def test_play_from_seeks_when_already_playing(
self,
- order_ops_coordinator: SequencerTabCoordinator,
+ order_ops_frames: SequencerFrames,
) -> None:
- coordinator = order_ops_coordinator
- coordinator._song_player_logic.is_playing.return_value = True
+ frames = order_ops_frames
+ frames._song_player_logic.is_playing.return_value = True
- coordinator._on_order_play_from(3)
+ frames.play_from(3)
- coordinator._song_player_logic.seek.assert_called_once_with(3)
- coordinator._song_player_logic.play_from.assert_not_called()
+ frames._song_player_logic.seek.assert_called_once_with(3)
+ frames._song_player_logic.play_from.assert_not_called()
def test_play_from_starts_playback_when_stopped(
self,
- order_ops_coordinator: SequencerTabCoordinator,
+ order_ops_frames: SequencerFrames,
) -> None:
- coordinator = order_ops_coordinator
- coordinator._song_player_logic.is_playing.return_value = False
+ frames = order_ops_frames
+ frames._song_player_logic.is_playing.return_value = False
- coordinator._on_order_play_from(3)
+ frames.play_from(3)
- coordinator._song_player_logic.play_from.assert_called_once_with(3)
- coordinator._song_player_logic.seek.assert_not_called()
+ frames._song_player_logic.play_from.assert_called_once_with(3)
+ frames._song_player_logic.seek.assert_not_called()
class TestImportReconstruction:
def test_closed_project_shows_dialog_and_does_not_import(
self,
- coordinator: SequencerTabCoordinator,
+ coordinator: SequencerReconstructions,
) -> None:
coordinator._project_controller.is_open = False
- coordinator.import_reconstruction(Path("reconstruction.stn"))
+ coordinator.import_from_file(Path("reconstruction.stn"))
coordinator._dialogs.show_info.assert_called_once()
- coordinator._sequencer_browser_logic.load_reconstruction.assert_not_called()
+ coordinator._browser_logic.load_reconstruction.assert_not_called()
coordinator._on_tab_switch.assert_not_called()
def test_successful_import_switches_to_sequencer_tab(
self,
- coordinator: SequencerTabCoordinator,
+ coordinator: SequencerReconstructions,
) -> None:
- reconstruction = coordinator._sequencer_browser_logic.load_reconstruction.return_value
+ reconstruction = coordinator._browser_logic.load_reconstruction.return_value
- coordinator.import_reconstruction(Path("reconstruction.stn"))
+ coordinator.import_from_file(Path("reconstruction.stn"))
- coordinator._sequencer_browser_logic.add_reconstruction.assert_called_once_with(
- reconstruction, "reconstruction"
- )
+ coordinator._browser_logic.add_reconstruction.assert_called_once_with(reconstruction, "reconstruction")
coordinator._on_tab_switch.assert_called_once_with(Tab.SEQUENCER)
coordinator._dialogs.show_info.assert_not_called()
coordinator._dialogs.show_confirmation.assert_not_called()
def test_failed_load_shows_error_and_does_not_switch_tab(
self,
- coordinator: SequencerTabCoordinator,
+ coordinator: SequencerReconstructions,
) -> None:
- coordinator._sequencer_browser_logic.load_reconstruction.side_effect = InvalidReconstructionValuesError(
+ coordinator._browser_logic.load_reconstruction.side_effect = InvalidReconstructionValuesError(
"invalid",
ValueError("inner"),
)
- coordinator.import_reconstruction(Path("reconstruction.stn"))
+ coordinator.import_from_file(Path("reconstruction.stn"))
coordinator._dialogs.show_error.assert_called_once()
- coordinator._sequencer_browser_logic.add_reconstruction.assert_not_called()
+ coordinator._browser_logic.add_reconstruction.assert_not_called()
coordinator._on_tab_switch.assert_not_called()
class TestImportFrequencyCheck:
def test_matching_frequency_adds_without_prompt_or_adopt(
self,
- coordinator: SequencerTabCoordinator,
+ coordinator: SequencerReconstructions,
) -> None:
- coordinator._sequencer_tracker_logic.settings.nes_frequency = 60
- coordinator._sequencer_browser_logic.load_reconstruction.return_value.config.nes_frequency = 60
+ coordinator._tracker_logic.settings.nes_frequency = 60
+ coordinator._browser_logic.load_reconstruction.return_value.config.nes_frequency = 60
- coordinator.import_reconstruction(Path("reconstruction.stn"))
+ coordinator.import_from_file(Path("reconstruction.stn"))
coordinator._dialogs.show_confirmation.assert_not_called()
- coordinator._sequencer_tracker_logic.set_nes_frequency.assert_not_called()
- coordinator._sequencer_browser_logic.add_reconstruction.assert_called_once()
+ coordinator._tracker_logic.set_nes_frequency.assert_not_called()
+ coordinator._browser_logic.add_reconstruction.assert_called_once()
def test_empty_project_adopts_reconstruction_frequency_silently(
self,
- coordinator: SequencerTabCoordinator,
+ coordinator: SequencerReconstructions,
) -> None:
- coordinator._project_controller.has_samples = False
- coordinator._sequencer_tracker_logic.settings.nes_frequency = 60
- coordinator._sequencer_browser_logic.load_reconstruction.return_value.config.nes_frequency = 50
+ coordinator._project_controller.has_voices = False
+ coordinator._tracker_logic.settings.nes_frequency = 60
+ coordinator._browser_logic.load_reconstruction.return_value.config.nes_frequency = 50
- coordinator.import_reconstruction(Path("reconstruction.stn"))
+ coordinator.import_from_file(Path("reconstruction.stn"))
- coordinator._sequencer_tracker_logic.set_nes_frequency.assert_called_once_with(50)
- coordinator._sequencer_browser_logic.add_reconstruction.assert_called_once()
+ coordinator._tracker_logic.set_nes_frequency.assert_called_once_with(50)
+ coordinator._browser_logic.add_reconstruction.assert_called_once()
coordinator._dialogs.show_confirmation.assert_not_called()
coordinator._on_tab_switch.assert_called_once_with(Tab.SEQUENCER)
def test_mismatch_with_samples_confirms_before_adding(
self,
- coordinator: SequencerTabCoordinator,
+ coordinator: SequencerReconstructions,
) -> None:
- coordinator._project_controller.has_samples = True
- coordinator._sequencer_tracker_logic.settings.nes_frequency = 60
- coordinator._sequencer_browser_logic.load_reconstruction.return_value.config.nes_frequency = 50
+ coordinator._project_controller.has_voices = True
+ coordinator._tracker_logic.settings.nes_frequency = 60
+ coordinator._browser_logic.load_reconstruction.return_value.config.nes_frequency = 50
- coordinator.import_reconstruction(Path("reconstruction.stn"))
+ coordinator.import_from_file(Path("reconstruction.stn"))
coordinator._dialogs.show_confirmation.assert_called_once()
- coordinator._sequencer_tracker_logic.set_nes_frequency.assert_not_called()
- coordinator._sequencer_browser_logic.add_reconstruction.assert_not_called()
+ coordinator._tracker_logic.set_nes_frequency.assert_not_called()
+ coordinator._browser_logic.add_reconstruction.assert_not_called()
coordinator._on_tab_switch.assert_not_called()
confirmation = coordinator._dialogs.show_confirmation.call_args.kwargs
assert confirmation["message"] == "recon 50 vs project 60"
confirmation["on_confirm"]()
- coordinator._sequencer_browser_logic.add_reconstruction.assert_called_once()
+ coordinator._browser_logic.add_reconstruction.assert_called_once()
coordinator._on_tab_switch.assert_called_once_with(Tab.SEQUENCER)
@pytest.fixture
-def replace_coordinator() -> SequencerTabCoordinator:
- """A coordinator with only the collaborators the browser replacement touches.
+def replace_coordinator() -> SequencerReconstructions:
+ """The reconstruction gestures with only the collaborators the browser replacement touches.
Defaults to a two-sample project holding ``1A: bass`` selected, against a reconstruction at the
project's frequency (60 Hz); individual tests override. ``_on_tab_switch`` stays absent, so a
replacement reaching for it would fail the test — the browser already lives in this tab.
"""
- instance = object.__new__(SequencerTabCoordinator)
- instance._history = MagicMock()
- instance._history_detail = MagicMock()
- instance._project_controller = MagicMock()
- instance._project_controller.sample_count = 2
- instance._sequencer_browser_logic = MagicMock()
- instance._sequencer_browser_logic.load_reconstruction.return_value.config.nes_frequency = 60
- instance._sequencer_tracker_logic = MagicMock()
- instance._sequencer_tracker_logic.settings.nes_frequency = 60
- instance._sequencer_samples_logic = MagicMock()
- instance._sequencer_samples_panel = MagicMock()
- instance._sequencer_samples_panel.selection = SampleSelection(
- sample_id="bass-id",
+ browser_logic = MagicMock()
+ browser_logic.load_reconstruction.return_value.config.nes_frequency = 60
+ tracker_logic = MagicMock()
+ tracker_logic.settings.nes_frequency = 60
+ project_controller = MagicMock()
+ project_controller.voice_count = 2
+ voices_panel = MagicMock()
+ voices_panel.selection = VoiceSelection(
+ voice_id="bass-id",
position=26,
name="bass",
+ kind=VoiceKind.SAMPLE,
+ )
+ return _reconstructions(
+ browser_logic,
+ tracker_logic,
+ project_controller,
+ MagicMock(),
+ voices_panel,
+ MagicMock(),
)
- instance._dialogs = MagicMock()
- instance._on_sample_reconstruction_replaced = MagicMock()
- instance._language_manager = FakeLanguageManager(TEXTS)
- return instance
class TestReplaceReconstruction:
def test_absent_selection_replaces_nothing(
self,
- replace_coordinator: SequencerTabCoordinator,
+ replace_coordinator: SequencerReconstructions,
) -> None:
- replace_coordinator._sequencer_samples_panel.selection = None
+ replace_coordinator._voices_panel.selection = None
- replace_coordinator.replace_reconstruction(Path("kick_02.stn"))
+ replace_coordinator.replace_from_file(Path("kick_02.stn"))
- replace_coordinator._sequencer_browser_logic.load_reconstruction.assert_not_called()
- replace_coordinator._sequencer_browser_logic.replace_reconstruction.assert_not_called()
+ replace_coordinator._browser_logic.load_reconstruction.assert_not_called()
+ replace_coordinator._browser_logic.replace_reconstruction.assert_not_called()
def test_failed_load_shows_error_and_replaces_nothing(
self,
- replace_coordinator: SequencerTabCoordinator,
+ replace_coordinator: SequencerReconstructions,
) -> None:
- replace_coordinator._sequencer_browser_logic.load_reconstruction.side_effect = InvalidReconstructionValuesError(
+ replace_coordinator._browser_logic.load_reconstruction.side_effect = InvalidReconstructionValuesError(
"invalid",
ValueError("inner"),
)
- replace_coordinator.replace_reconstruction(Path("kick_02.stn"))
+ replace_coordinator.replace_from_file(Path("kick_02.stn"))
replace_coordinator._dialogs.show_error.assert_called_once()
- replace_coordinator._sequencer_browser_logic.replace_reconstruction.assert_not_called()
- replace_coordinator._sequencer_samples_logic.rename_sample.assert_not_called()
+ replace_coordinator._browser_logic.replace_reconstruction.assert_not_called()
+ replace_coordinator._voices_logic.rename_voice.assert_not_called()
replace_coordinator._on_sample_reconstruction_replaced.assert_not_called()
def test_selected_sample_is_renamed_and_substituted(
self,
- replace_coordinator: SequencerTabCoordinator,
+ replace_coordinator: SequencerReconstructions,
) -> None:
- reconstruction = replace_coordinator._sequencer_browser_logic.load_reconstruction.return_value
+ reconstruction = replace_coordinator._browser_logic.load_reconstruction.return_value
- replace_coordinator.replace_reconstruction(Path("/reconstructions/kick_02.stn"))
+ replace_coordinator.replace_from_file(Path("/reconstructions/kick_02.stn"))
- replace_coordinator._sequencer_samples_logic.rename_sample.assert_called_once_with(
+ replace_coordinator._voices_logic.rename_voice.assert_called_once_with(
"bass-id",
"kick_02",
)
- replace_coordinator._sequencer_browser_logic.replace_reconstruction.assert_called_once_with(
+ replace_coordinator._browser_logic.replace_reconstruction.assert_called_once_with(
"bass-id",
reconstruction,
)
replace_coordinator._dialogs.show_confirmation.assert_not_called()
- replace_coordinator._sequencer_tracker_logic.set_nes_frequency.assert_not_called()
+ replace_coordinator._tracker_logic.set_nes_frequency.assert_not_called()
def test_rename_and_substitution_share_one_history_entry(
self,
- replace_coordinator: SequencerTabCoordinator,
+ replace_coordinator: SequencerReconstructions,
) -> None:
- replace_coordinator.replace_reconstruction(Path("kick_02.stn"))
+ replace_coordinator.replace_from_file(Path("kick_02.stn"))
replace_coordinator._history.transaction.assert_called_once_with(
HistoryAction.REPLACE_SAMPLE,
@@ -698,35 +1000,35 @@ def test_rename_and_substitution_share_one_history_entry(
def test_detail_reads_the_sample_before_it_is_substituted(
self,
- replace_coordinator: SequencerTabCoordinator,
+ replace_coordinator: SequencerReconstructions,
) -> None:
"""The detail names the outgoing reconstruction, which the sample only holds until the swap."""
order = MagicMock()
order.attach_mock(replace_coordinator._history_detail.replace_sample, "detail")
order.attach_mock(
- replace_coordinator._sequencer_browser_logic.replace_reconstruction,
+ replace_coordinator._browser_logic.replace_reconstruction,
"replace",
)
- replace_coordinator.replace_reconstruction(Path("kick_02.stn"))
+ replace_coordinator.replace_from_file(Path("kick_02.stn"))
assert [call[0] for call in order.mock_calls] == ["detail", "replace"]
replace_coordinator._history_detail.replace_sample.assert_called_once_with("bass-id", "kick_02")
def test_replacement_is_announced_before_the_substitution(
self,
- replace_coordinator: SequencerTabCoordinator,
+ replace_coordinator: SequencerReconstructions,
) -> None:
"""An editor holding the sample open identifies it by the reconstruction the swap replaces."""
- reconstruction = replace_coordinator._sequencer_browser_logic.load_reconstruction.return_value
+ reconstruction = replace_coordinator._browser_logic.load_reconstruction.return_value
order = MagicMock()
order.attach_mock(replace_coordinator._on_sample_reconstruction_replaced, "announce")
order.attach_mock(
- replace_coordinator._sequencer_browser_logic.replace_reconstruction,
+ replace_coordinator._browser_logic.replace_reconstruction,
"replace",
)
- replace_coordinator.replace_reconstruction(Path("kick_02.stn"))
+ replace_coordinator.replace_from_file(Path("kick_02.stn"))
assert [call[0] for call in order.mock_calls] == ["announce", "replace"]
replace_coordinator._on_sample_reconstruction_replaced.assert_called_once_with(
@@ -736,65 +1038,75 @@ def test_replacement_is_announced_before_the_substitution(
def test_sole_sample_adopts_the_reconstruction_frequency_silently(
self,
- replace_coordinator: SequencerTabCoordinator,
+ replace_coordinator: SequencerReconstructions,
) -> None:
- replace_coordinator._project_controller.sample_count = 1
- replace_coordinator._sequencer_browser_logic.load_reconstruction.return_value.config.nes_frequency = 50
+ replace_coordinator._project_controller.voice_count = 1
+ replace_coordinator._browser_logic.load_reconstruction.return_value.config.nes_frequency = 50
- replace_coordinator.replace_reconstruction(Path("kick_02.stn"))
+ replace_coordinator.replace_from_file(Path("kick_02.stn"))
- replace_coordinator._sequencer_tracker_logic.set_nes_frequency.assert_called_once_with(50)
- replace_coordinator._sequencer_browser_logic.replace_reconstruction.assert_called_once()
+ replace_coordinator._tracker_logic.set_nes_frequency.assert_called_once_with(50)
+ replace_coordinator._browser_logic.replace_reconstruction.assert_called_once()
replace_coordinator._dialogs.show_confirmation.assert_not_called()
def test_mismatch_beside_other_samples_confirms_before_replacing(
self,
- replace_coordinator: SequencerTabCoordinator,
+ replace_coordinator: SequencerReconstructions,
) -> None:
- replace_coordinator._sequencer_browser_logic.load_reconstruction.return_value.config.nes_frequency = 50
+ replace_coordinator._browser_logic.load_reconstruction.return_value.config.nes_frequency = 50
- replace_coordinator.replace_reconstruction(Path("kick_02.stn"))
+ replace_coordinator.replace_from_file(Path("kick_02.stn"))
replace_coordinator._dialogs.show_confirmation.assert_called_once()
- replace_coordinator._sequencer_browser_logic.replace_reconstruction.assert_not_called()
+ replace_coordinator._browser_logic.replace_reconstruction.assert_not_called()
replace_coordinator._on_sample_reconstruction_replaced.assert_not_called()
confirmation = replace_coordinator._dialogs.show_confirmation.call_args.kwargs
assert confirmation["message"] == "recon 50 vs project 60"
confirmation["on_confirm"]()
- replace_coordinator._sequencer_browser_logic.replace_reconstruction.assert_called_once()
- replace_coordinator._sequencer_tracker_logic.set_nes_frequency.assert_not_called()
+ replace_coordinator._browser_logic.replace_reconstruction.assert_called_once()
+ replace_coordinator._tracker_logic.set_nes_frequency.assert_not_called()
class TestReplaceTargetLabel:
def test_label_names_the_selected_sample(
self,
- replace_coordinator: SequencerTabCoordinator,
+ replace_coordinator: SequencerReconstructions,
) -> None:
- assert replace_coordinator._replace_target_label() == "1A: bass"
+ assert replace_coordinator.replace_target_label() == "1A: bass"
def test_label_is_absent_without_a_selection(
self,
- replace_coordinator: SequencerTabCoordinator,
+ replace_coordinator: SequencerReconstructions,
) -> None:
- replace_coordinator._sequencer_samples_panel.selection = None
+ replace_coordinator._voices_panel.selection = None
- assert replace_coordinator._replace_target_label() is None
+ assert replace_coordinator.replace_target_label() is None
@pytest.fixture
def history_coordinator() -> SequencerTabCoordinator:
- """A coordinator with the two collaborators an undoable gesture reaches.
+ """A coordinator whose history is a mock, so a test reads what a delegation asked of it."""
+ instance = object.__new__(SequencerTabCoordinator)
+ instance._history = MagicMock()
+ return instance
+
+
+@pytest.fixture
+def recorder() -> SequencerHistoryRecorder:
+ """A recorder with the two collaborators an undoable gesture reaches.
The history is a mock, so a test reads the transaction a gesture opens; the
controller is real, so a test reads the notifications the gesture's mutations
actually produce.
"""
- instance = object.__new__(SequencerTabCoordinator)
- instance._history = MagicMock()
- instance._project_controller = ProjectController(ProjectManager())
- return instance
+ return SequencerHistoryRecorder(
+ MagicMock(),
+ ProjectController(ProjectManager()),
+ MagicMock(),
+ language_manager=MagicMock(),
+ )
@pytest.fixture
@@ -826,7 +1138,7 @@ def wired_history_coordinator(
class TestHistoryResetWiring:
def test_project_replacement_reseeds_history(
self,
- wired_history_coordinator: SequencerTabCoordinator,
+ wired_history_coordinator: SequencerReconstructions,
) -> None:
coordinator = wired_history_coordinator
controller = coordinator._project_controller
@@ -843,7 +1155,7 @@ def test_project_replacement_reseeds_history(
def test_closing_the_project_empties_history(
self,
- wired_history_coordinator: SequencerTabCoordinator,
+ wired_history_coordinator: SequencerReconstructions,
) -> None:
coordinator = wired_history_coordinator
controller = coordinator._project_controller
@@ -857,7 +1169,7 @@ def test_closing_the_project_empties_history(
def test_undo_keeps_the_stack_it_navigates(
self,
- wired_history_coordinator: SequencerTabCoordinator,
+ wired_history_coordinator: SequencerReconstructions,
) -> None:
coordinator = wired_history_coordinator
controller = coordinator._project_controller
@@ -882,42 +1194,42 @@ class TestChannelMuteLifetime:
def test_undo_keeps_the_mute_set(
self,
- wired_history_coordinator: SequencerTabCoordinator,
+ wired_history_coordinator: SequencerReconstructions,
) -> None:
coordinator = wired_history_coordinator
controller = coordinator._project_controller
channels = coordinator._sequencer_channels_logic
with coordinator._history.transaction(HistoryAction.SET_TEMPO):
controller.set_tempo(150)
- channels.toggle(GeneratorName.TRIANGLE)
+ channels.toggle(ChannelName.TRIANGLE)
coordinator.undo()
- assert channels.active_channels == ALL_CHANNELS - {GeneratorName.TRIANGLE}
+ assert channels.active_channels == ALL_CHANNELS - {ChannelName.TRIANGLE}
def test_redo_keeps_the_mute_set(
self,
- wired_history_coordinator: SequencerTabCoordinator,
+ wired_history_coordinator: SequencerReconstructions,
) -> None:
coordinator = wired_history_coordinator
controller = coordinator._project_controller
channels = coordinator._sequencer_channels_logic
with coordinator._history.transaction(HistoryAction.SET_TEMPO):
controller.set_tempo(150)
- channels.toggle(GeneratorName.NOISE)
+ channels.toggle(ChannelName.NOISE)
coordinator.undo()
coordinator.redo()
- assert channels.active_channels == ALL_CHANNELS - {GeneratorName.NOISE}
+ assert channels.active_channels == ALL_CHANNELS - {ChannelName.NOISE}
def test_opening_a_project_restores_every_channel(
self,
- wired_history_coordinator: SequencerTabCoordinator,
+ wired_history_coordinator: SequencerReconstructions,
) -> None:
coordinator = wired_history_coordinator
channels = coordinator._sequencer_channels_logic
- channels.solo(GeneratorName.TRIANGLE)
+ channels.solo(ChannelName.TRIANGLE)
coordinator._project_controller.new()
@@ -925,11 +1237,11 @@ def test_opening_a_project_restores_every_channel(
def test_closing_the_project_restores_every_channel(
self,
- wired_history_coordinator: SequencerTabCoordinator,
+ wired_history_coordinator: SequencerReconstructions,
) -> None:
coordinator = wired_history_coordinator
channels = coordinator._sequencer_channels_logic
- channels.toggle(GeneratorName.PULSE1)
+ channels.toggle(ChannelName.PULSE1)
coordinator._project_controller.close()
@@ -940,7 +1252,7 @@ def test_closing_the_project_restores_every_channel(
def channels_coordinator(monkeypatch: pytest.MonkeyPatch) -> SequencerTabCoordinator:
"""A coordinator joining the real channels logic to a real grid panel and a real order panel.
- Each panel's colour cues reach DearPyGui, which holds no context here, so the tables are
+ Each panel's color cues reach DearPyGui, which holds no context here, so the tables are
reported absent and a panel stops once it has recorded the mute set — which is what the
wiring is read for. The menu bar above the tab is a recorder, so a test can read whether it
was told. Modifiers are reported as held nowhere; a test that needs Ctrl says so.
@@ -968,53 +1280,53 @@ class TestChannelHeaderWiring:
def test_header_click_silences_that_channel(
self,
- channels_coordinator: SequencerTabCoordinator,
+ channels_coordinator: SequencerReconstructions,
) -> None:
panel = channels_coordinator._sequencer_tracker_panel
- panel._on_header_clicked(0, True, GeneratorName.TRIANGLE)
+ panel._on_header_clicked(0, True, ChannelName.TRIANGLE)
- assert channels_coordinator._sequencer_channels_logic.active_channels == ALL_CHANNELS - {GeneratorName.TRIANGLE}
- assert panel._is_muted(GeneratorName.TRIANGLE)
+ assert channels_coordinator._sequencer_channels_logic.active_channels == ALL_CHANNELS - {ChannelName.TRIANGLE}
+ assert panel._is_muted(ChannelName.TRIANGLE)
def test_a_second_click_returns_the_channel_to_the_mix(
self,
- channels_coordinator: SequencerTabCoordinator,
+ channels_coordinator: SequencerReconstructions,
) -> None:
panel = channels_coordinator._sequencer_tracker_panel
- panel._on_header_clicked(0, True, GeneratorName.TRIANGLE)
- panel._on_header_clicked(0, True, GeneratorName.TRIANGLE)
+ panel._on_header_clicked(0, True, ChannelName.TRIANGLE)
+ panel._on_header_clicked(0, True, ChannelName.TRIANGLE)
assert channels_coordinator._sequencer_channels_logic.active_channels == ALL_CHANNELS
- assert not panel._is_muted(GeneratorName.TRIANGLE)
+ assert not panel._is_muted(ChannelName.TRIANGLE)
def test_ctrl_header_click_solos_that_channel(
self,
- channels_coordinator: SequencerTabCoordinator,
+ channels_coordinator: SequencerReconstructions,
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(channels_module, "capture_modifiers", lambda: CTRL)
panel = channels_coordinator._sequencer_tracker_panel
- panel._on_header_clicked(0, True, GeneratorName.PULSE2)
+ panel._on_header_clicked(0, True, ChannelName.PULSE2)
- assert channels_coordinator._sequencer_channels_logic.active_channels == frozenset({GeneratorName.PULSE2})
+ assert channels_coordinator._sequencer_channels_logic.active_channels == frozenset({ChannelName.PULSE2})
def test_sample_header_click_silences_every_channel(
self,
- channels_coordinator: SequencerTabCoordinator,
+ channels_coordinator: SequencerReconstructions,
) -> None:
panel = channels_coordinator._sequencer_tracker_panel
panel._on_header_clicked(0, True, None)
assert channels_coordinator._sequencer_channels_logic.active_channels == frozenset()
- assert all(panel._is_muted(generator) for generator in GeneratorName.items())
+ assert all(panel._is_muted(channel) for channel in ChannelName.items())
def test_sample_header_click_restores_every_channel_from_full_silence(
self,
- channels_coordinator: SequencerTabCoordinator,
+ channels_coordinator: SequencerReconstructions,
) -> None:
panel = channels_coordinator._sequencer_tracker_panel
@@ -1022,31 +1334,31 @@ def test_sample_header_click_restores_every_channel_from_full_silence(
panel._on_header_clicked(0, True, None)
assert channels_coordinator._sequencer_channels_logic.active_channels == ALL_CHANNELS
- assert not any(panel._is_muted(generator) for generator in GeneratorName.items())
+ assert not any(panel._is_muted(channel) for channel in ChannelName.items())
def test_the_menu_silences_every_channel_from_a_mixed_set(
self,
- channels_coordinator: SequencerTabCoordinator,
+ channels_coordinator: SequencerReconstructions,
) -> None:
panel = channels_coordinator._sequencer_tracker_panel
- panel._on_header_clicked(0, True, GeneratorName.TRIANGLE)
+ panel._on_header_clicked(0, True, ChannelName.TRIANGLE)
panel.call(panel.on_channels_muted)
assert channels_coordinator._sequencer_channels_logic.active_channels == frozenset()
- assert all(panel._is_muted(generator) for generator in GeneratorName.items())
+ assert all(panel._is_muted(channel) for channel in ChannelName.items())
def test_the_menu_restores_every_channel_from_a_mixed_set(
self,
- channels_coordinator: SequencerTabCoordinator,
+ channels_coordinator: SequencerReconstructions,
) -> None:
panel = channels_coordinator._sequencer_tracker_panel
- panel._on_header_clicked(0, True, GeneratorName.TRIANGLE)
+ panel._on_header_clicked(0, True, ChannelName.TRIANGLE)
panel.call(panel.on_channels_unmuted)
assert channels_coordinator._sequencer_channels_logic.active_channels == ALL_CHANNELS
- assert not any(panel._is_muted(generator) for generator in GeneratorName.items())
+ assert not any(panel._is_muted(channel) for channel in ChannelName.items())
class TestChannelRowLabelWiring:
@@ -1054,30 +1366,30 @@ class TestChannelRowLabelWiring:
def test_row_label_click_silences_that_channel(
self,
- channels_coordinator: SequencerTabCoordinator,
+ channels_coordinator: SequencerReconstructions,
) -> None:
order_panel = channels_coordinator._sequencer_order_panel
- order_panel._on_label_clicked(0, True, GeneratorName.NOISE)
+ order_panel._on_label_clicked(0, True, ChannelName.NOISE)
- assert channels_coordinator._sequencer_channels_logic.active_channels == ALL_CHANNELS - {GeneratorName.NOISE}
- assert order_panel._is_muted(GeneratorName.NOISE)
+ assert channels_coordinator._sequencer_channels_logic.active_channels == ALL_CHANNELS - {ChannelName.NOISE}
+ assert order_panel._is_muted(ChannelName.NOISE)
def test_ctrl_row_label_click_solos_that_channel(
self,
- channels_coordinator: SequencerTabCoordinator,
+ channels_coordinator: SequencerReconstructions,
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(channels_module, "capture_modifiers", lambda: CTRL)
order_panel = channels_coordinator._sequencer_order_panel
- order_panel._on_label_clicked(0, True, GeneratorName.PULSE1)
+ order_panel._on_label_clicked(0, True, ChannelName.PULSE1)
- assert channels_coordinator._sequencer_channels_logic.active_channels == frozenset({GeneratorName.PULSE1})
+ assert channels_coordinator._sequencer_channels_logic.active_channels == frozenset({ChannelName.PULSE1})
def test_master_row_label_click_silences_every_channel(
self,
- channels_coordinator: SequencerTabCoordinator,
+ channels_coordinator: SequencerReconstructions,
) -> None:
order_panel = channels_coordinator._sequencer_order_panel
@@ -1087,32 +1399,32 @@ def test_master_row_label_click_silences_every_channel(
def test_a_tracker_click_reaches_the_order_table(
self,
- channels_coordinator: SequencerTabCoordinator,
+ channels_coordinator: SequencerReconstructions,
) -> None:
tracker_panel = channels_coordinator._sequencer_tracker_panel
order_panel = channels_coordinator._sequencer_order_panel
- tracker_panel._on_header_clicked(0, True, GeneratorName.TRIANGLE)
+ tracker_panel._on_header_clicked(0, True, ChannelName.TRIANGLE)
- assert order_panel._is_muted(GeneratorName.TRIANGLE)
+ assert order_panel._is_muted(ChannelName.TRIANGLE)
def test_an_order_click_reaches_the_tracker(
self,
- channels_coordinator: SequencerTabCoordinator,
+ channels_coordinator: SequencerReconstructions,
) -> None:
tracker_panel = channels_coordinator._sequencer_tracker_panel
order_panel = channels_coordinator._sequencer_order_panel
- order_panel._on_label_clicked(0, True, GeneratorName.PULSE2)
+ order_panel._on_label_clicked(0, True, ChannelName.PULSE2)
- assert tracker_panel._is_muted(GeneratorName.PULSE2)
+ assert tracker_panel._is_muted(ChannelName.PULSE2)
def test_the_order_menu_silences_every_channel(
self,
- channels_coordinator: SequencerTabCoordinator,
+ channels_coordinator: SequencerReconstructions,
) -> None:
order_panel = channels_coordinator._sequencer_order_panel
- order_panel._on_label_clicked(0, True, GeneratorName.TRIANGLE)
+ order_panel._on_label_clicked(0, True, ChannelName.TRIANGLE)
order_panel.call(order_panel.on_channels_muted)
@@ -1120,10 +1432,10 @@ def test_the_order_menu_silences_every_channel(
def test_the_order_menu_restores_every_channel(
self,
- channels_coordinator: SequencerTabCoordinator,
+ channels_coordinator: SequencerReconstructions,
) -> None:
order_panel = channels_coordinator._sequencer_order_panel
- order_panel._on_label_clicked(0, True, GeneratorName.TRIANGLE)
+ order_panel._on_label_clicked(0, True, ChannelName.TRIANGLE)
order_panel.call(order_panel.on_channels_unmuted)
@@ -1135,34 +1447,34 @@ class TestChannelMenuWiring:
def test_the_menu_reads_the_mute_set_back(
self,
- channels_coordinator: SequencerTabCoordinator,
+ channels_coordinator: SequencerReconstructions,
) -> None:
- channels_coordinator._sequencer_channels_logic.toggle(GeneratorName.NOISE)
+ channels_coordinator._sequencer_channels_logic.toggle(ChannelName.NOISE)
- assert channels_coordinator.channels.muted == frozenset({GeneratorName.NOISE})
+ assert channels_coordinator.channels.muted == frozenset({ChannelName.NOISE})
def test_toggling_a_channel_silences_it(
self,
- channels_coordinator: SequencerTabCoordinator,
+ channels_coordinator: SequencerReconstructions,
) -> None:
- channels_coordinator.toggle_channel(GeneratorName.PULSE1)
+ channels_coordinator.toggle_channel(ChannelName.PULSE1)
- assert channels_coordinator._sequencer_channels_logic.active_channels == ALL_CHANNELS - {GeneratorName.PULSE1}
+ assert channels_coordinator._sequencer_channels_logic.active_channels == ALL_CHANNELS - {ChannelName.PULSE1}
def test_toggling_a_channel_twice_returns_it_to_the_mix(
self,
- channels_coordinator: SequencerTabCoordinator,
+ channels_coordinator: SequencerReconstructions,
) -> None:
- channels_coordinator.toggle_channel(GeneratorName.PULSE1)
- channels_coordinator.toggle_channel(GeneratorName.PULSE1)
+ channels_coordinator.toggle_channel(ChannelName.PULSE1)
+ channels_coordinator.toggle_channel(ChannelName.PULSE1)
assert channels_coordinator._sequencer_channels_logic.active_channels == ALL_CHANNELS
def test_the_menu_restores_every_channel_from_a_solo(
self,
- channels_coordinator: SequencerTabCoordinator,
+ channels_coordinator: SequencerReconstructions,
) -> None:
- channels_coordinator._sequencer_channels_logic.solo(GeneratorName.TRIANGLE)
+ channels_coordinator._sequencer_channels_logic.solo(ChannelName.TRIANGLE)
channels_coordinator.unmute_all_channels()
@@ -1170,18 +1482,18 @@ def test_the_menu_restores_every_channel_from_a_solo(
def test_a_menu_toggle_shows_in_both_tables(
self,
- channels_coordinator: SequencerTabCoordinator,
+ channels_coordinator: SequencerReconstructions,
) -> None:
- channels_coordinator.toggle_channel(GeneratorName.TRIANGLE)
+ channels_coordinator.toggle_channel(ChannelName.TRIANGLE)
- assert channels_coordinator._sequencer_tracker_panel._is_muted(GeneratorName.TRIANGLE)
- assert channels_coordinator._sequencer_order_panel._is_muted(GeneratorName.TRIANGLE)
+ assert channels_coordinator._sequencer_tracker_panel._is_muted(ChannelName.TRIANGLE)
+ assert channels_coordinator._sequencer_order_panel._is_muted(ChannelName.TRIANGLE)
def test_a_table_click_tells_the_menu_bar(
self,
- channels_coordinator: SequencerTabCoordinator,
+ channels_coordinator: SequencerReconstructions,
) -> None:
- channels_coordinator._sequencer_tracker_panel._on_header_clicked(0, True, GeneratorName.TRIANGLE)
+ channels_coordinator._sequencer_tracker_panel._on_header_clicked(0, True, ChannelName.TRIANGLE)
channels_coordinator._on_channels_changed.assert_called_once_with()
@@ -1204,13 +1516,13 @@ def test_jump_delegates_to_history(self, history_coordinator: SequencerTabCoordi
class TestUndoableWrapper:
- def test_wrapped_call_runs_inside_a_transaction(self, history_coordinator: SequencerTabCoordinator) -> None:
+ def test_wrapped_call_runs_inside_a_transaction(self, recorder: SequencerHistoryRecorder) -> None:
target = MagicMock()
- wrapped = history_coordinator._undoable(HistoryAction.SET_TEMPO, target)
+ wrapped = recorder.undoable(HistoryAction.SET_TEMPO, target)
wrapped(150)
- history_coordinator._history.transaction.assert_called_once_with(
+ recorder._history.transaction.assert_called_once_with(
HistoryAction.SET_TEMPO,
detail=(),
coalesce=None,
@@ -1219,19 +1531,19 @@ def test_wrapped_call_runs_inside_a_transaction(self, history_coordinator: Seque
def test_wrapped_call_passes_computed_detail(
self,
- history_coordinator: SequencerTabCoordinator,
+ recorder: SequencerHistoryRecorder,
) -> None:
target = MagicMock()
segments = (HistoryDetailSegment(text="v150", role=HistoryDetailRole.VALUE),)
- wrapped = history_coordinator._undoable(
+ wrapped = recorder.undoable(
HistoryAction.SET_TEMPO,
target,
detail=lambda _: segments,
)
wrapped(150)
- history_coordinator._history.transaction.assert_called_once_with(
+ recorder._history.transaction.assert_called_once_with(
HistoryAction.SET_TEMPO,
detail=segments,
coalesce=None,
@@ -1239,18 +1551,18 @@ def test_wrapped_call_passes_computed_detail(
def test_wrapped_call_passes_computed_coalesce_key(
self,
- history_coordinator: SequencerTabCoordinator,
+ recorder: SequencerHistoryRecorder,
) -> None:
target = MagicMock()
- wrapped = history_coordinator._undoable(
+ wrapped = recorder.undoable(
HistoryAction.SET_TEMPO,
target,
coalesce=lambda _: ("tempo",),
)
wrapped(150)
- history_coordinator._history.transaction.assert_called_once_with(
+ recorder._history.transaction.assert_called_once_with(
HistoryAction.SET_TEMPO,
detail=(),
coalesce=("tempo",),
@@ -1258,9 +1570,9 @@ def test_wrapped_call_passes_computed_coalesce_key(
def test_wrapped_call_announces_one_song_change_for_the_whole_gesture(
self,
- history_coordinator: SequencerTabCoordinator,
+ recorder: SequencerHistoryRecorder,
) -> None:
- controller = history_coordinator._project_controller
+ controller = recorder._project_controller
announcements: List[str] = []
controller.on_song_changed = lambda: announcements.append("song")
initial_length = controller.order_length
@@ -1269,7 +1581,7 @@ def append_frames(count: int) -> None:
for _ in range(count):
controller.append_frame()
- wrapped = history_coordinator._undoable(HistoryAction.EDIT_ROW, append_frames)
+ wrapped = recorder.undoable(HistoryAction.EDIT_ROW, append_frames)
wrapped(3)
assert controller.order_length == initial_length + 3
@@ -1277,45 +1589,41 @@ def append_frames(count: int) -> None:
@pytest.fixture
-def view_coordinator() -> SequencerTabCoordinator:
- """A coordinator with only the collaborators the history view build touches."""
- instance = object.__new__(SequencerTabCoordinator)
- instance._history = MagicMock()
- instance._language_manager = LanguageManager(LANG_EN)
- return instance
+def view_recorder() -> SequencerHistoryRecorder:
+ """A recorder with only the collaborators the history view build touches."""
+ return SequencerHistoryRecorder(
+ MagicMock(),
+ MagicMock(),
+ MagicMock(),
+ language_manager=LanguageManager(LANG_EN),
+ )
-def _loop_entry(loop: bool) -> HistoryEntry:
- word = HistoryDetailWord.LOOP_ON if loop else HistoryDetailWord.LOOP_OFF
+def _detail_entry(value: str) -> HistoryEntry:
return HistoryEntry(
project=MagicMock(),
- action=HistoryAction.SET_SAMPLE_LOOP,
+ action=HistoryAction.MOVE_VOICE,
created=datetime.now(tz=UTC),
detail=(
HistoryDetailSegment(text="00:", role=HistoryDetailRole.SAMPLE),
- HistoryDetailWordSegment(word=word, role=HistoryDetailRole.VALUE),
+ HistoryDetailSegment(text=value, role=HistoryDetailRole.VALUE),
),
)
class TestHistoryViewModelBuild:
- def test_word_segments_resolve_to_language_text(
+ def test_an_entry_reaches_the_view_with_the_detail_it_was_committed_with(
self,
- view_coordinator: SequencerTabCoordinator,
+ view_recorder: SequencerHistoryRecorder,
) -> None:
- view_coordinator._history.cursor = 1
- view_coordinator._history.entries = (_loop_entry(True), _loop_entry(False))
+ """A detail is built in the words it is read in, so the view shows what was stored."""
+ view_recorder._history.cursor = 1
+ entries = (_detail_entry("01"), _detail_entry("02"))
+ view_recorder._history.entries = entries
- view_model = view_coordinator._build_history_view_model()
+ view_model = view_recorder.view_model()
- assert view_model.entries[0].detail_segments == (
- HistoryDetailSegment(text="00:", role=HistoryDetailRole.SAMPLE),
- HistoryDetailSegment(text="on", role=HistoryDetailRole.VALUE),
- )
- assert view_model.entries[1].detail_segments == (
- HistoryDetailSegment(text="00:", role=HistoryDetailRole.SAMPLE),
- HistoryDetailSegment(text="off", role=HistoryDetailRole.VALUE),
- )
+ assert [entry.detail_segments for entry in view_model.entries] == [entry.detail for entry in entries]
@pytest.fixture
@@ -1342,12 +1650,12 @@ def test_player_returns_the_guarded_wrapper(
PULSE1_CELL: Final[TrackerRegion] = TrackerRegion(
first_row=0,
last_row=0,
- first_slot=TrackerSlot(GeneratorName.PULSE1, SubColumn.INSTRUMENT).flat_index,
- last_slot=TrackerSlot(GeneratorName.PULSE1, SubColumn.VOLUME).flat_index,
+ first_slot=TrackerSlot(ChannelName.PULSE1, SubColumn.VOICE).flat_index,
+ last_slot=TrackerSlot(ChannelName.PULSE1, SubColumn.VOLUME).flat_index,
)
PULSE1_FRAME: Final[OrderRegion] = OrderRegion(
- first_row=CHANNEL_AXIS.index(GeneratorName.PULSE1),
- last_row=CHANNEL_AXIS.index(GeneratorName.PULSE1),
+ first_row=CHANNEL_AXIS.index(ChannelName.PULSE1),
+ last_row=CHANNEL_AXIS.index(ChannelName.PULSE1),
first_position=0,
last_position=0,
)
@@ -1366,6 +1674,13 @@ def write(self, text: str) -> None:
self.text = text
+def _text_clipboard(coordinator: SequencerTabCoordinator) -> FakeTextClipboard:
+ """The desktop clipboard this coordinator's blocks were built over."""
+ clipboard = coordinator._blocks._text_clipboard
+ assert isinstance(clipboard, FakeTextClipboard)
+ return clipboard
+
+
@pytest.fixture
def block_coordinator() -> SequencerTabCoordinator:
"""A coordinator whose block path is real, from the tracker logic through to the clipboard.
@@ -1384,17 +1699,19 @@ def block_coordinator() -> SequencerTabCoordinator:
instance._project_controller = controller
instance._history = history
instance._sequencer_tracker_logic = SequencerTrackerLogic(controller)
- instance._clipboard = SequencerClipboard()
- instance._system_clipboard = FakeTextClipboard()
- instance._tracker_block_text = TrackerBlockText(samples=ProjectSampleDirectory(controller))
- instance._order_block_text = OrderBlockText()
- instance._tracker_text_cache = ParsedBlockCache(instance._tracker_block_text.parse)
- instance._order_text_cache = ParsedBlockCache(instance._order_block_text.parse)
- instance._tracker_block_reader = TrackerBlockReader(instance._sequencer_tracker_logic)
- instance._tracker_block_writer = TrackerBlockWriter(instance._sequencer_tracker_logic)
instance._sequencer_order_logic = SequencerOrderLogic(controller)
- instance._order_block_reader = OrderBlockReader(instance._sequencer_order_logic)
- instance._order_block_writer = OrderBlockWriter(instance._sequencer_order_logic)
+ instance._recorder = SequencerHistoryRecorder(
+ history,
+ controller,
+ instance._sequencer_tracker_logic,
+ language_manager=MagicMock(),
+ )
+ instance._blocks = SequencerBlocks(
+ instance._sequencer_tracker_logic,
+ instance._sequencer_order_logic,
+ controller,
+ text_clipboard=FakeTextClipboard(),
+ )
instance._history_detail = SequencerHistoryDetail(
instance._sequencer_tracker_logic,
MagicMock(),
@@ -1406,15 +1723,15 @@ def block_coordinator() -> SequencerTabCoordinator:
def _place_transpose(
- coordinator: SequencerTabCoordinator,
+ coordinator: SequencerReconstructions,
transpose: int,
) -> None:
"""Puts one value in the frame, through the same wrapper an edit reaches the history by."""
- edit = coordinator._undoable(
+ edit = coordinator._recorder.undoable(
HistoryAction.EDIT_ROW,
coordinator._sequencer_tracker_logic.write_cell,
)
- edit(0, GeneratorName.PULSE1, None, transpose, None)
+ edit(0, ChannelName.PULSE1, None, transpose, None)
class TestBlockCopy:
@@ -1426,13 +1743,13 @@ def test_a_copy_fills_the_clipboard_with_the_block_it_covers(
with coordinator._history.transaction(HistoryAction.EDIT_ROW):
coordinator._sequencer_tracker_logic.set_cell_subcolumn(
0,
- GeneratorName.PULSE1,
+ ChannelName.PULSE1,
transpose=5,
)
- coordinator._on_tracker_copy_block(PULSE1_CELL)
+ coordinator._blocks.copy_tracker(PULSE1_CELL)
- block = coordinator._clipboard.tracker_block
+ block = coordinator._blocks._clipboard.tracker_block
assert block is not None
assert block.transposes[(0, 1)] == 5
@@ -1445,7 +1762,7 @@ def test_a_copy_leaves_the_history_stack_as_it_stands(
_place_transpose(coordinator, 5)
recorded = len(coordinator._history.entries)
- coordinator._on_tracker_copy_block(PULSE1_CELL)
+ coordinator._blocks.copy_tracker(PULSE1_CELL)
assert recorded > 0
assert len(coordinator._history.entries) == recorded
@@ -1463,10 +1780,10 @@ def test_a_cut_takes_the_block_and_empties_what_it_covered(
coordinator._sequencer_tracker_panel.on_cut_block(PULSE1_CELL)
- block = coordinator._clipboard.tracker_block
+ block = coordinator._blocks._clipboard.tracker_block
assert block is not None
assert block.transposes[(0, 1)] == 5
- assert coordinator._sequencer_tracker_logic.row(GeneratorName.PULSE1, 0).transpose is None
+ assert coordinator._sequencer_tracker_logic.row(ChannelName.PULSE1, 0).transpose is None
def test_a_cut_records_one_entry(
self,
@@ -1491,7 +1808,7 @@ def test_a_delete_empties_the_region_in_one_entry(
coordinator._sequencer_tracker_panel.on_delete_block(PULSE1_CELL)
- assert coordinator._sequencer_tracker_logic.row(GeneratorName.PULSE1, 0).transpose is None
+ assert coordinator._sequencer_tracker_logic.row(ChannelName.PULSE1, 0).transpose is None
assert len(coordinator._history.entries) == recorded + 1
assert coordinator._history.entries[-1].action is HistoryAction.DELETE_BLOCK
@@ -1501,12 +1818,12 @@ def test_a_paste_writes_the_copied_block_in_one_entry(
) -> None:
coordinator = block_coordinator
_place_transpose(coordinator, 5)
- coordinator._on_tracker_copy_block(PULSE1_CELL)
+ coordinator._blocks.copy_tracker(PULSE1_CELL)
recorded = len(coordinator._history.entries)
- coordinator._sequencer_tracker_panel.on_paste_block(TrackerCell(row=1, generator=GeneratorName.PULSE2))
+ coordinator._sequencer_tracker_panel.on_paste_block(TrackerCell(row=1, channel=ChannelName.PULSE2))
- assert coordinator._sequencer_tracker_logic.row(GeneratorName.PULSE2, 1).transpose == 5
+ assert coordinator._sequencer_tracker_logic.row(ChannelName.PULSE2, 1).transpose == 5
assert len(coordinator._history.entries) == recorded + 1
assert coordinator._history.entries[-1].action is HistoryAction.PASTE_BLOCK
@@ -1523,7 +1840,7 @@ def test_a_copy_fills_the_clipboard_and_leaves_the_history_as_it_stands(
coordinator._sequencer_order_panel.on_copy_block(PULSE1_FRAME)
- block = coordinator._clipboard.order_block
+ block = coordinator._blocks._clipboard.order_block
assert block is not None
assert block.entries == {(0, 0): 0}
assert len(coordinator._history.entries) == recorded
@@ -1537,7 +1854,7 @@ def test_a_cut_takes_the_block_and_silences_what_it_covered(
coordinator._sequencer_order_panel.on_cut_block(PULSE1_FRAME)
- assert coordinator._sequencer_order_logic.entry(GeneratorName.PULSE1, 0) is None
+ assert coordinator._sequencer_order_logic.entry(ChannelName.PULSE1, 0) is None
assert len(coordinator._history.entries) == recorded + 1
assert coordinator._history.entries[-1].action is HistoryAction.CUT_BLOCK
@@ -1550,7 +1867,7 @@ def test_a_delete_silences_the_region_in_one_entry(
coordinator._sequencer_order_panel.on_delete_block(PULSE1_FRAME)
- assert coordinator._sequencer_order_logic.entry(GeneratorName.PULSE1, 0) is None
+ assert coordinator._sequencer_order_logic.entry(ChannelName.PULSE1, 0) is None
assert len(coordinator._history.entries) == recorded + 1
assert coordinator._history.entries[-1].action is HistoryAction.DELETE_BLOCK
@@ -1563,10 +1880,10 @@ def test_a_paste_covers_the_frames_it_appends_and_the_entries_it_writes(
coordinator._sequencer_order_panel.on_copy_block(PULSE1_FRAME)
recorded = len(coordinator._history.entries)
- coordinator._sequencer_order_panel.on_paste_block(OrderCell(generator=GeneratorName.NOISE, position=1))
+ coordinator._sequencer_order_panel.on_paste_block(OrderCell(channel=ChannelName.NOISE, position=1))
assert coordinator._sequencer_order_logic.position_count() == 2
- assert coordinator._sequencer_order_logic.entry(GeneratorName.NOISE, 1) == 0
+ assert coordinator._sequencer_order_logic.entry(ChannelName.NOISE, 1) == 0
assert len(coordinator._history.entries) == recorded + 1
assert coordinator._history.entries[-1].action is HistoryAction.PASTE_BLOCK
@@ -1584,7 +1901,7 @@ def test_a_paste_with_nothing_copied_records_nothing(
_place_transpose(coordinator, 5)
recorded = len(coordinator._history.entries)
- coordinator._sequencer_tracker_panel.on_paste_block(TrackerCell(row=1, generator=GeneratorName.PULSE2))
+ coordinator._sequencer_tracker_panel.on_paste_block(TrackerCell(row=1, channel=ChannelName.PULSE2))
assert len(coordinator._history.entries) == recorded
@@ -1599,9 +1916,9 @@ def test_a_copy_states_the_block_as_text(
coordinator = block_coordinator
_place_transpose(coordinator, 5)
- coordinator._on_tracker_copy_block(PULSE1_CELL)
+ coordinator._blocks.copy_tracker(PULSE1_CELL)
- assert coordinator._system_clipboard.text == "SampleToNES/1 tracker rows=1 slots=3..5\n.. +05 ."
+ assert _text_clipboard(coordinator).text == "SampleToNES/1 tracker rows=1 slots=3..5\n.. +05 ."
def test_an_order_copy_states_its_own_grid(
self,
@@ -1609,9 +1926,9 @@ def test_an_order_copy_states_its_own_grid(
) -> None:
coordinator = block_coordinator
- coordinator._on_order_copy_block(PULSE1_FRAME)
+ coordinator._blocks.copy_order(PULSE1_FRAME)
- assert coordinator._system_clipboard.text == "SampleToNES/1 order rows=1 positions=0..0\n00"
+ assert _text_clipboard(coordinator).text == "SampleToNES/1 order rows=1 positions=0..0\n00"
def test_a_cut_states_the_block_it_took(
self,
@@ -1622,7 +1939,7 @@ def test_a_cut_states_the_block_it_took(
coordinator._sequencer_tracker_panel.on_cut_block(PULSE1_CELL)
- assert coordinator._system_clipboard.text == "SampleToNES/1 tracker rows=1 slots=3..5\n.. +05 ."
+ assert _text_clipboard(coordinator).text == "SampleToNES/1 tracker rows=1 slots=3..5\n.. +05 ."
class TestSystemClipboardPrecedence:
@@ -1635,12 +1952,12 @@ def test_a_block_copied_elsewhere_is_the_one_a_paste_writes(
"""This is a second instance's copy arriving, which is what carries a block between them."""
coordinator = block_coordinator
_place_transpose(coordinator, 5)
- coordinator._on_tracker_copy_block(PULSE1_CELL)
- coordinator._system_clipboard.write("SampleToNES/1 tracker rows=1 slots=3..5\n.. +09 .")
+ coordinator._blocks.copy_tracker(PULSE1_CELL)
+ _text_clipboard(coordinator).write("SampleToNES/1 tracker rows=1 slots=3..5\n.. +09 .")
- coordinator._sequencer_tracker_panel.on_paste_block(TrackerCell(row=1, generator=GeneratorName.PULSE1))
+ coordinator._sequencer_tracker_panel.on_paste_block(TrackerCell(row=1, channel=ChannelName.PULSE1))
- assert coordinator._sequencer_tracker_logic.row(GeneratorName.PULSE1, 1).transpose == 9
+ assert coordinator._sequencer_tracker_logic.row(ChannelName.PULSE1, 1).transpose == 9
def test_unrelated_text_leaves_the_copied_block_in_hand(
self,
@@ -1648,12 +1965,12 @@ def test_unrelated_text_leaves_the_copied_block_in_hand(
) -> None:
coordinator = block_coordinator
_place_transpose(coordinator, 5)
- coordinator._on_tracker_copy_block(PULSE1_CELL)
- coordinator._system_clipboard.write("a line from a message")
+ coordinator._blocks.copy_tracker(PULSE1_CELL)
+ _text_clipboard(coordinator).write("a line from a message")
- coordinator._sequencer_tracker_panel.on_paste_block(TrackerCell(row=1, generator=GeneratorName.PULSE1))
+ coordinator._sequencer_tracker_panel.on_paste_block(TrackerCell(row=1, channel=ChannelName.PULSE1))
- assert coordinator._sequencer_tracker_logic.row(GeneratorName.PULSE1, 1).transpose == 5
+ assert coordinator._sequencer_tracker_logic.row(ChannelName.PULSE1, 1).transpose == 5
def test_a_truncated_block_leaves_the_copied_block_in_hand(
self,
@@ -1661,12 +1978,12 @@ def test_a_truncated_block_leaves_the_copied_block_in_hand(
) -> None:
coordinator = block_coordinator
_place_transpose(coordinator, 5)
- coordinator._on_tracker_copy_block(PULSE1_CELL)
- coordinator._system_clipboard.write("SampleToNES/1 tracker rows=4 slots=3..5\n.. +09 .")
+ coordinator._blocks.copy_tracker(PULSE1_CELL)
+ _text_clipboard(coordinator).write("SampleToNES/1 tracker rows=4 slots=3..5\n.. +09 .")
- coordinator._sequencer_tracker_panel.on_paste_block(TrackerCell(row=1, generator=GeneratorName.PULSE1))
+ coordinator._sequencer_tracker_panel.on_paste_block(TrackerCell(row=1, channel=ChannelName.PULSE1))
- assert coordinator._sequencer_tracker_logic.row(GeneratorName.PULSE1, 1).transpose == 5
+ assert coordinator._sequencer_tracker_logic.row(ChannelName.PULSE1, 1).transpose == 5
def test_the_other_grid_s_text_leaves_the_copied_block_in_hand(
self,
@@ -1674,12 +1991,12 @@ def test_the_other_grid_s_text_leaves_the_copied_block_in_hand(
) -> None:
"""A tracker copy stands on the clipboard while the order pastes, so each grid keeps its own."""
coordinator = block_coordinator
- coordinator._on_order_copy_block(PULSE1_FRAME)
- coordinator._system_clipboard.write("SampleToNES/1 tracker rows=1 slots=3..5\n.. +09 .")
+ coordinator._blocks.copy_order(PULSE1_FRAME)
+ _text_clipboard(coordinator).write("SampleToNES/1 tracker rows=1 slots=3..5\n.. +09 .")
- coordinator._sequencer_order_panel.on_paste_block(OrderCell(generator=GeneratorName.NOISE, position=1))
+ coordinator._sequencer_order_panel.on_paste_block(OrderCell(channel=ChannelName.NOISE, position=1))
- assert coordinator._sequencer_order_logic.entry(GeneratorName.NOISE, 1) == 0
+ assert coordinator._sequencer_order_logic.entry(ChannelName.NOISE, 1) == 0
def test_a_paste_offers_itself_on_the_text_standing_on_the_clipboard(
self,
@@ -1688,9 +2005,9 @@ def test_a_paste_offers_itself_on_the_text_standing_on_the_clipboard(
"""The menu asks the same question the paste does, so it offers what the next press reaches."""
coordinator = block_coordinator
- assert not coordinator._can_paste_tracker_block()
+ assert not coordinator._blocks.can_paste_tracker()
- coordinator._system_clipboard.write("SampleToNES/1 tracker rows=1 slots=3..5\n.. +09 .")
+ _text_clipboard(coordinator).write("SampleToNES/1 tracker rows=1 slots=3..5\n.. +09 .")
- assert coordinator._can_paste_tracker_block()
- assert not coordinator._can_paste_order_block()
+ assert coordinator._blocks.can_paste_tracker()
+ assert not coordinator._blocks.can_paste_order()
diff --git a/tests/unit/sampletones_application/coordinators/test_keybindings.py b/tests/unit/sampletones_application/coordinators/test_keybindings.py
index 56ee13d14..bf69dd41d 100644
--- a/tests/unit/sampletones_application/coordinators/test_keybindings.py
+++ b/tests/unit/sampletones_application/coordinators/test_keybindings.py
@@ -187,9 +187,9 @@ def test_every_editable_action_reaches_a_row(self, harness: Harness) -> None:
assert listed == editable
def test_every_row_carries_a_label_a_reader_sees(self, harness: Harness) -> None:
- unlabelled = [row.action for group in harness.window.view_model.groups for row in group.rows if not row.label]
+ unlabeled = [row.action for group in harness.window.view_model.groups for row in group.rows if not row.label]
- assert unlabelled == []
+ assert unlabeled == []
def test_every_shipped_scheme_is_offered(self, harness: Harness) -> None:
assert harness.window.view_model.schemes == shipped_catalog().names
diff --git a/tests/unit/sampletones_application/coordinators/test_project.py b/tests/unit/sampletones_application/coordinators/test_project.py
index e811e956b..a32fd6344 100644
--- a/tests/unit/sampletones_application/coordinators/test_project.py
+++ b/tests/unit/sampletones_application/coordinators/test_project.py
@@ -24,7 +24,7 @@ def project_coordinator() -> ProjectCoordinator:
MagicMock(),
MagicMock(),
MagicMock(),
- tracker_backends={},
+ export_backends={},
dialogs=MagicMock(),
language_manager=MagicMock(),
on_tab_switch=MagicMock(),
diff --git a/tests/unit/sampletones_application/coordinators/test_reconstruction.py b/tests/unit/sampletones_application/coordinators/test_reconstruction.py
index efe251588..3e505776f 100644
--- a/tests/unit/sampletones_application/coordinators/test_reconstruction.py
+++ b/tests/unit/sampletones_application/coordinators/test_reconstruction.py
@@ -6,10 +6,11 @@
import pytest
from sampletones_application.coordinators.reconstruction import ReconstructionCoordinator
+from sampletones_application.logic.reconstruction.edit import StemRemoval
from sampletones_application.logic.reconstruction.manager import ReconstructionManager
-from sampletones_application.services.regeneration import RegeneratedInstrument
+from sampletones_application.services.regeneration.service import RegeneratedInstrument
from sampletones_application.services.result import ServiceSuccess
-from sampletones_core.constants.enums import FeatureKey, GeneratorName
+from sampletones_core.constants.enums import ChannelName, FeatureKey
from sampletones_core.reconstructions import Reconstruction
from sampletones_shared.exceptions import (
InvalidMetadataError,
@@ -118,7 +119,7 @@ def test_history_hook_sees_prior_reconstruction_identity(
self,
reconstruction_factory: ReconstructionFactory,
) -> None:
- """Pins the hook-before-apply order in ``_on_updated``.
+ """Pins the hook-before-apply order in ``apply_edit``.
The hook locates the owning project sample by identity against the prior
reconstruction, so it must observe the manager before the document rebinds
@@ -144,7 +145,7 @@ def test_history_hook_sees_prior_reconstruction_identity(
regenerated = reconstruction_factory()
outcome = RegeneratedInstrument(
reconstruction=regenerated,
- generator_name=GeneratorName.PULSE1,
+ channel_name=ChannelName.PULSE1,
feature_key=FeatureKey.VOLUME,
)
@@ -155,6 +156,37 @@ def test_history_hook_sees_prior_reconstruction_identity(
assert manager.reconstruction is regenerated
+class TestStemRemovalApplyOrdering:
+ def test_a_removed_recording_travels_the_same_path_as_a_regenerated_instrument(
+ self,
+ reconstruction_factory: ReconstructionFactory,
+ ) -> None:
+ """Every edit of the open document is applied alike, so the history sees them alike."""
+ manager = ReconstructionManager(scheduling=MagicMock())
+ prior = reconstruction_factory()
+ manager.load_reconstruction_object(prior, name="lead")
+ observed: List[Optional[Reconstruction]] = []
+ coordinator = ReconstructionCoordinator(
+ manager,
+ MagicMock(),
+ MagicMock(),
+ MagicMock(),
+ dialogs=MagicMock(),
+ language_manager=MagicMock(),
+ on_tab_switch=MagicMock(),
+ on_session_state_changed=MagicMock(),
+ on_reconstruction_updated=lambda _edit: observed.append(manager.reconstruction),
+ is_reconstruction_embedded=lambda: False,
+ )
+ coordinator.set_reconstructions_tab(MagicMock())
+ remaining = reconstruction_factory()
+
+ coordinator.apply_edit(StemRemoval(reconstruction=remaining, stem_name="kick"))
+
+ assert observed == [prior]
+ assert manager.reconstruction is remaining
+
+
class TestReconstructionRestorePropagatesUnexpected:
def test_runtime_error_propagates(
self,
diff --git a/tests/unit/sampletones_application/coordinators/test_render.py b/tests/unit/sampletones_application/coordinators/test_render.py
index 8e642d443..e973f4e6d 100644
--- a/tests/unit/sampletones_application/coordinators/test_render.py
+++ b/tests/unit/sampletones_application/coordinators/test_render.py
@@ -10,7 +10,7 @@
from sampletones_application.logic.project.manager import ProjectManager
from sampletones_application.logic.render.logic import SongRenderLogic
from sampletones_application.services.result import (
- ServiceCancelled,
+ ServiceCanceled,
ServiceError,
ServiceSuccess,
)
@@ -331,7 +331,7 @@ def test_a_stopped_render_closes_without_a_report(self, render: RenderFixture) -
render.start()
render.stop()
- render.service.emit(ServiceCancelled())
+ render.service.emit(ServiceCanceled())
render.advance_frame()
assert render.window.hides == 1
@@ -343,9 +343,9 @@ def test_a_stopped_render_closes_without_a_report(self, render: RenderFixture) -
[
ServiceSuccess(value=CHOSEN),
ServiceError(exception=OSError("no room on the device")),
- ServiceCancelled(),
+ ServiceCanceled(),
],
- ids=["completed", "failed", "cancelled"],
+ ids=["completed", "failed", "canceled"],
)
def test_every_outcome_hands_the_application_back(
self,
diff --git a/tests/unit/sampletones_application/logic/export/__init__.py b/tests/unit/sampletones_application/logic/export/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/tests/unit/sampletones_application/logic/export/instrument/__init__.py b/tests/unit/sampletones_application/logic/export/instrument/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/tests/unit/sampletones_application/logic/export/instrument/test_logic.py b/tests/unit/sampletones_application/logic/export/instrument/test_logic.py
new file mode 100644
index 000000000..15e4b20a4
--- /dev/null
+++ b/tests/unit/sampletones_application/logic/export/instrument/test_logic.py
@@ -0,0 +1,213 @@
+from dataclasses import dataclass
+from pathlib import Path
+from typing import Callable, Dict, Final, List
+from unittest.mock import MagicMock
+
+import pytest
+
+from sampletones_application.exports import build_export_backends
+from sampletones_application.logic.export.instrument.logic import InstrumentExportLogic
+from sampletones_application.logic.export.instrument.source import (
+ exportable_instrument,
+ voice_entries,
+)
+from sampletones_core.exports.format import ExportFormat
+from sampletones_core.exports.request import InstrumentSource
+from sampletones_core.project.project import Project
+from sampletones_core.project.voices.creation import new_instrument
+from sampletones_core.project.voices.sample import Sample
+from sampletones_core.reconstructions import Reconstruction
+from sampletones_shared.paths.extensions import (
+ EXT_FILE_BITPHASE,
+ EXT_FILE_INSTRUMENT,
+ EXT_FILE_JSON,
+ EXT_FILE_MODULE,
+ EXT_FILE_NSF,
+)
+
+NO_EXTENSION: Final[str] = ""
+REMEMBERED_DIRECTORY: Final[Path] = Path("/instruments")
+
+
+@dataclass(frozen=True)
+class FormatCase:
+ extension: str
+ export_format: ExportFormat
+
+
+FORMAT_CASES: Final[List[FormatCase]] = [
+ FormatCase(extension=EXT_FILE_INSTRUMENT, export_format=ExportFormat.FAMITRACKER),
+ FormatCase(extension=EXT_FILE_BITPHASE, export_format=ExportFormat.BITPHASE),
+ FormatCase(extension=EXT_FILE_JSON, export_format=ExportFormat.BITPHASE_PRESET),
+ FormatCase(extension=EXT_FILE_NSF, export_format=ExportFormat.NSF),
+]
+
+UNSUPPORTED_EXTENSIONS: Final[List[str]] = [".xm", EXT_FILE_MODULE, NO_EXTENSION]
+
+
+@pytest.fixture
+def session_manager() -> MagicMock:
+ mock = MagicMock()
+ mock.get_instrument_path.return_value = REMEMBERED_DIRECTORY
+ return mock
+
+
+@pytest.fixture
+def export_service() -> MagicMock:
+ return MagicMock()
+
+
+@pytest.fixture
+def export_backends() -> Dict[ExportFormat, MagicMock]:
+ """Stands in for the real backends while declaring the scopes and extensions they do."""
+ backends: Dict[ExportFormat, MagicMock] = {}
+ for export_format, backend in build_export_backends().items():
+ stub = MagicMock()
+ stub.supported_scopes = backend.supported_scopes
+ stub.extension.side_effect = backend.extension
+ backends[export_format] = stub
+
+ return backends
+
+
+@pytest.fixture
+def project_controller() -> MagicMock:
+ mock = MagicMock()
+ mock.project = Project.create()
+ return mock
+
+
+@pytest.fixture
+def logic(
+ project_controller: MagicMock,
+ session_manager: MagicMock,
+ export_service: MagicMock,
+ export_backends: Dict[ExportFormat, MagicMock],
+) -> InstrumentExportLogic:
+ return InstrumentExportLogic(project_controller, session_manager, export_service, export_backends)
+
+
+def _source() -> InstrumentSource:
+ voice = new_instrument("Lead")
+ project = Project.create()
+ project.voices.append(voice)
+ return exportable_instrument(project, voice_entries(voice)[0]).source
+
+
+class TestWritingOneInstrument:
+ """One path carries an instrument to disk, whichever surface asked for it."""
+
+ def test_the_service_is_asked_to_write_it(
+ self,
+ logic: InstrumentExportLogic,
+ export_service: MagicMock,
+ tmp_path: Path,
+ ) -> None:
+ logic.export(tmp_path / f"instrument{EXT_FILE_INSTRUMENT}", _source())
+
+ export_service.export_instrument.assert_called_once()
+
+ def test_the_destination_names_the_instrument(
+ self,
+ logic: InstrumentExportLogic,
+ export_service: MagicMock,
+ tmp_path: Path,
+ ) -> None:
+ """Renaming the file in the save dialog renames the instrument the file carries."""
+ logic.export(tmp_path / f"Clap (pulse1){EXT_FILE_INSTRUMENT}", _source())
+
+ request = export_service.export_instrument.call_args.args[2]
+ assert request.name == "Clap (pulse1)"
+
+ @pytest.mark.parametrize("case", FORMAT_CASES, ids=lambda case: case.extension)
+ def test_the_extension_names_the_backend(
+ self,
+ logic: InstrumentExportLogic,
+ export_service: MagicMock,
+ export_backends: Dict[ExportFormat, MagicMock],
+ tmp_path: Path,
+ case: FormatCase,
+ ) -> None:
+ logic.export(tmp_path / f"instrument{case.extension}", _source())
+
+ backend = export_service.export_instrument.call_args.args[1]
+ assert backend is export_backends[case.export_format]
+
+ def test_everything_the_source_states_reaches_the_request(
+ self,
+ logic: InstrumentExportLogic,
+ export_service: MagicMock,
+ tmp_path: Path,
+ ) -> None:
+ source = _source()
+
+ logic.export(tmp_path / f"instrument{EXT_FILE_INSTRUMENT}", source)
+
+ request = export_service.export_instrument.call_args.args[2]
+ assert (request.channel, request.features) == (
+ source.channel,
+ source.features,
+ )
+ assert (request.nes_frequency, request.tuning) == (source.nes_frequency, source.tuning)
+
+ def test_the_folder_it_landed_in_is_remembered(
+ self,
+ logic: InstrumentExportLogic,
+ session_manager: MagicMock,
+ tmp_path: Path,
+ ) -> None:
+ logic.export(tmp_path / f"instrument{EXT_FILE_INSTRUMENT}", _source())
+
+ session_manager.set_instrument_path.assert_called_once_with(tmp_path)
+
+ def test_the_dialog_opens_where_the_last_one_landed(self, logic: InstrumentExportLogic) -> None:
+ assert logic.suggested_directory == REMEMBERED_DIRECTORY
+
+ def test_every_backend_is_reachable_for_the_types_a_dialog_offers(
+ self,
+ logic: InstrumentExportLogic,
+ export_backends: Dict[ExportFormat, MagicMock],
+ ) -> None:
+ """The dialog names each format's own file type, which it reads off the backend."""
+ assert dict(logic.backends) == export_backends
+
+ @pytest.mark.parametrize("extension", UNSUPPORTED_EXTENSIONS)
+ def test_an_extension_no_format_writes_is_refused(
+ self,
+ logic: InstrumentExportLogic,
+ 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.
+ """
+ with pytest.raises(ValueError):
+ logic.export(tmp_path / f"instrument{extension}", _source())
+
+
+class TestWhatTheOpenProjectOffers:
+ """A menu names a voice alone, so the pool it belongs to is the one the logic holds."""
+
+ def test_a_sample_of_the_open_project_offers_each_channel_that_plays(
+ self,
+ logic: InstrumentExportLogic,
+ project_controller: MagicMock,
+ reconstruction_factory: Callable[[], Reconstruction],
+ ) -> None:
+ sample = Sample(name="Bass", reconstruction=reconstruction_factory())
+ project_controller.project.voices.append(sample)
+
+ assert logic.voice_instruments(sample.id) == tuple(sample.reconstruction.playing_channels)
+
+ def test_the_instrument_asked_for_comes_from_the_open_project(
+ self,
+ logic: InstrumentExportLogic,
+ project_controller: MagicMock,
+ ) -> None:
+ voice = new_instrument("Lead")
+ project_controller.project.voices.append(voice)
+
+ exportable = logic.voice_instrument(voice.id, None)
+
+ assert exportable is not None
+ assert exportable.name == "Lead"
diff --git a/tests/unit/sampletones_application/logic/export/instrument/test_source.py b/tests/unit/sampletones_application/logic/export/instrument/test_source.py
new file mode 100644
index 000000000..a27b75cc4
--- /dev/null
+++ b/tests/unit/sampletones_application/logic/export/instrument/test_source.py
@@ -0,0 +1,164 @@
+from typing import Callable
+
+from sampletones_application.constants.instruments import INSTRUMENT_CHANNEL
+from sampletones_application.logic.export.instrument.source import (
+ exportable_instrument,
+ sounding_channel,
+ voice_entries,
+ voice_instrument,
+ voice_instrument_channels,
+)
+from sampletones_core.constants.enums import ChannelName
+from sampletones_core.exports.request import InstrumentSource
+from sampletones_core.project.project import Project
+from sampletones_core.project.voices.creation import new_instrument
+from sampletones_core.project.voices.sample import Sample
+from sampletones_core.project.voices.voice import VoiceUnion
+from sampletones_core.reconstructions import Reconstruction
+from sampletones_shared.music import Tuning
+from tests.suite.sequencer import sample_reconstruction
+
+
+def _project(*voices: VoiceUnion) -> Project:
+ project = Project.create()
+ for voice in voices:
+ project.voices.append(voice)
+
+ return project
+
+
+class TestWhatAVoiceOffers:
+ """One rule answers what a voice contributes, so both kinds are exported the same way."""
+
+ def test_a_written_instrument_offers_one(self) -> None:
+ """One set of envelopes every channel reads is one instrument, as a module holds it."""
+ voice = new_instrument("Lead")
+
+ assert len(voice_entries(voice)) == 1
+
+ def test_a_written_instrument_is_named_after_itself(self) -> None:
+ voice = new_instrument("Lead")
+
+ assert voice_entries(voice)[0].name == "Lead"
+
+ def test_a_sample_offers_one_per_channel_that_plays(
+ self,
+ reconstruction_factory: Callable[[], Reconstruction],
+ ) -> None:
+ sample = Sample(name="Bass", reconstruction=reconstruction_factory())
+ playing = sample.reconstruction.playing_channels
+
+ assert [entry.channel for entry in voice_entries(sample)] == list(playing)
+
+ def test_a_samples_slice_is_named_for_its_channel(
+ self,
+ reconstruction_factory: Callable[[], Reconstruction],
+ ) -> None:
+ sample = Sample(name="Bass", reconstruction=reconstruction_factory())
+
+ assert all(entry.name.startswith("Bass") for entry in voice_entries(sample))
+
+ def test_both_kinds_answer_with_the_same_shape(
+ self,
+ reconstruction_factory: Callable[[], Reconstruction],
+ ) -> None:
+ """Whatever produced the envelopes is settled here, so one export path takes both."""
+ sample = Sample(name="Bass", reconstruction=reconstruction_factory())
+ written = new_instrument("Lead")
+ project = _project(sample, written)
+
+ sources = [exportable_instrument(project, entry).source for entry in voice_entries(sample)]
+ sources += [exportable_instrument(project, entry).source for entry in voice_entries(written)]
+
+ assert all(isinstance(source, InstrumentSource) for source in sources)
+
+ def test_the_project_states_the_rate_and_the_tuning(self) -> None:
+ voice = new_instrument("Lead")
+ project = _project(voice)
+
+ source = exportable_instrument(project, voice_entries(voice)[0]).source
+
+ assert source.nes_frequency == project.settings.nes_frequency
+ assert source.tuning == Tuning()
+
+ def test_a_voice_with_nothing_written_offers_nothing(self) -> None:
+ sample = Sample(name="Silent", reconstruction=sample_reconstruction(set()))
+
+ assert voice_entries(sample) == ()
+
+
+class TestWhichChannelAFileSoundsItOn:
+ """A written file plays its instrument somewhere, and the entry states where or leaves it open."""
+
+ def test_a_samples_slice_sounds_on_the_channel_it_was_reconstructed_for(
+ self,
+ reconstruction_factory: Callable[[], Reconstruction],
+ ) -> None:
+ sample = Sample(name="Bass", reconstruction=reconstruction_factory())
+
+ for entry in voice_entries(sample):
+ assert sounding_channel(entry) is entry.channel
+
+ def test_a_written_instrument_sounds_where_the_app_reads_it(self) -> None:
+ """Its envelopes name no channel, so the file plays them where its editor shows them."""
+ voice = new_instrument("Lead")
+
+ assert sounding_channel(voice_entries(voice)[0]) is INSTRUMENT_CHANNEL
+
+ def test_a_written_instrument_is_measured_against_the_root_that_channel_reads(self) -> None:
+ """The channel it is sounded on and the reference its envelopes carry are one answer."""
+ voice = new_instrument("Lead")
+ entry = voice_entries(voice)[0]
+
+ assert entry.features.initial_pitch == voice.reference(sounding_channel(entry))
+
+
+class TestWhatOneVoiceIsAskedFor:
+ """A menu asks what a voice offers, and a click asks for one of them by the channel it named."""
+
+ def test_a_sample_offers_each_channel_that_plays(
+ self,
+ reconstruction_factory: Callable[[], Reconstruction],
+ ) -> None:
+ sample = Sample(name="Bass", reconstruction=reconstruction_factory())
+ project = _project(sample)
+
+ assert voice_instrument_channels(project, sample.id) == tuple(sample.reconstruction.playing_channels)
+
+ def test_a_written_instrument_offers_one_naming_no_channel(self) -> None:
+ voice = new_instrument("Lead")
+
+ assert voice_instrument_channels(_project(voice), voice.id) == (None,)
+
+ def test_a_voice_the_pool_lost_offers_nothing(self) -> None:
+ assert voice_instrument_channels(Project.create(), "gone") == ()
+
+ def test_the_instrument_asked_for_is_the_one_that_channel_names(
+ self,
+ reconstruction_factory: Callable[[], Reconstruction],
+ ) -> None:
+ sample = Sample(name="Bass", reconstruction=reconstruction_factory())
+ project = _project(sample)
+ channel = sample.reconstruction.playing_channels[0]
+
+ exportable = voice_instrument(project, sample.id, channel)
+
+ assert exportable is not None
+ assert exportable.source.channel is channel
+
+ def test_a_written_instrument_is_asked_for_by_naming_no_channel(self) -> None:
+ voice = new_instrument("Lead")
+
+ exportable = voice_instrument(_project(voice), voice.id, None)
+
+ assert exportable is not None
+ assert exportable.name == "Lead"
+
+ def test_a_written_instrument_is_reached_by_naming_no_channel_alone(self) -> None:
+ """It offers one instrument for every channel at once, so no channel names it by itself."""
+ voice = new_instrument("Lead")
+
+ assert voice_instrument(_project(voice), voice.id, ChannelName.NOISE) is None
+
+ def test_a_voice_the_pool_lost_is_written_nowhere(self) -> None:
+ assert voice_instrument(Project.create(), "gone", None) is None
diff --git a/tests/unit/sampletones_application/logic/export/test_logic.py b/tests/unit/sampletones_application/logic/export/test_logic.py
new file mode 100644
index 000000000..0f456cc32
--- /dev/null
+++ b/tests/unit/sampletones_application/logic/export/test_logic.py
@@ -0,0 +1,305 @@
+from typing import Callable, Final, List, Optional
+
+import pytest
+
+from sampletones_application.logic.export import SongExportLogic
+from sampletones_application.services.export.kind import ExportKind
+from sampletones_application.services.export.result import ExportResult, ExportSuccess
+from sampletones_application.services.result import (
+ ServiceCanceled,
+ ServiceProgress,
+ ServiceStarted,
+)
+from sampletones_application.view_model.shared.export import (
+ ExportPhase,
+ SongExportViewModel,
+)
+from sampletones_core.exports.stage import ExportStage
+
+WALKING_LABEL: Final[str] = "Playing the song out"
+COMPRESSING_LABEL: Final[str] = "Compressing the song"
+WRITING_LABEL: Final[str] = "Writing the file"
+SIZE_TEMPLATE: Final[str] = "{completed} of {total} bytes"
+CANCELLING_LABEL: Final[str] = "Stopping the export..."
+NOTHING_MEASURED: Final[int] = 0
+PROGRAM_AREA: Final[int] = 32429
+SONG_TICKS: Final[int] = 14400
+REACHED_SIZE: Final[int] = 8761
+WALKED_TICKS: Final[int] = 7200
+
+
+class FakeExportService:
+ """The export service as the dialog's logic drives it, holding what it was asked to do."""
+
+ def __init__(self, *, running: bool = True) -> None:
+ self.running = running
+ self.cancels: int = 0
+ self.shutdowns: int = 0
+ self._handler: Optional[Callable[[ExportResult], None]] = None
+
+ def subscribe(self, handler: Callable[[ExportResult], None]) -> None:
+ self._handler = handler
+
+ def cancel(self) -> None:
+ self.cancels += 1
+
+ def is_running(self) -> bool:
+ return self.running
+
+ def shutdown(self) -> None:
+ self.shutdowns += 1
+
+ def deliver(self, result: ExportResult) -> None:
+ assert self._handler is not None
+ self._handler(result)
+
+
+def progress(
+ stage: ExportStage,
+ completed: int,
+ total: int,
+) -> ServiceProgress[ExportStage]:
+ return ServiceProgress(completed=completed, total=total, current_item=stage)
+
+
+@pytest.fixture(name="service")
+def service_fixture() -> FakeExportService:
+ return FakeExportService()
+
+
+@pytest.fixture(name="logic")
+def logic_fixture(service: FakeExportService) -> SongExportLogic:
+ return SongExportLogic(
+ service,
+ stage_labels={
+ ExportStage.WALKING: WALKING_LABEL,
+ ExportStage.COMPRESSING: COMPRESSING_LABEL,
+ ExportStage.WRITING: WRITING_LABEL,
+ },
+ size_template=SIZE_TEMPLATE,
+ cancelling_label=CANCELLING_LABEL,
+ )
+
+
+@pytest.fixture(name="views")
+def views_fixture(logic: SongExportLogic) -> List[SongExportViewModel]:
+ views: List[SongExportViewModel] = []
+ logic.on_view_changed = views.append
+ return views
+
+
+def finished() -> ExportSuccess:
+ from pathlib import Path
+
+ return ExportSuccess(
+ kind=ExportKind.SAMPLE,
+ filepath=Path("song.nsf"),
+ export_format=None,
+ truncation=None,
+ )
+
+
+class TestFollowingARun:
+ """The stages are listed as the format reaches them, so the run's shape is learned."""
+
+ def test_a_start_puts_the_dialog_on_screen(
+ self,
+ logic: SongExportLogic,
+ service: FakeExportService,
+ ) -> None:
+ opened: List[bool] = []
+ logic.on_started = lambda: opened.append(True)
+ service.deliver(ServiceStarted(total=NOTHING_MEASURED))
+ assert opened == [True]
+
+ def test_a_stage_joins_the_list_when_the_run_reaches_it(
+ self,
+ logic: SongExportLogic,
+ service: FakeExportService,
+ views: List[SongExportViewModel],
+ ) -> None:
+ service.deliver(ServiceStarted(total=NOTHING_MEASURED))
+ service.deliver(progress(ExportStage.WALKING, WALKED_TICKS, SONG_TICKS))
+ service.deliver(progress(ExportStage.COMPRESSING, REACHED_SIZE, PROGRAM_AREA))
+ assert views[-1].stages == (ExportStage.WALKING, ExportStage.COMPRESSING)
+
+ def test_a_stage_reported_again_is_listed_once(
+ self,
+ logic: SongExportLogic,
+ service: FakeExportService,
+ views: List[SongExportViewModel],
+ ) -> None:
+ service.deliver(ServiceStarted(total=NOTHING_MEASURED))
+ service.deliver(progress(ExportStage.COMPRESSING, REACHED_SIZE, PROGRAM_AREA))
+ service.deliver(progress(ExportStage.COMPRESSING, REACHED_SIZE - 100, PROGRAM_AREA))
+ assert views[-1].stages == (ExportStage.COMPRESSING,)
+
+ def test_a_second_run_starts_the_list_over(
+ self,
+ logic: SongExportLogic,
+ service: FakeExportService,
+ views: List[SongExportViewModel],
+ ) -> None:
+ service.deliver(ServiceStarted(total=NOTHING_MEASURED))
+ service.deliver(progress(ExportStage.WRITING, 1, 1))
+ service.deliver(finished())
+ service.deliver(ServiceStarted(total=NOTHING_MEASURED))
+ assert views[-1].stages == ()
+
+
+class TestHowEachStageReads:
+ """A stage traveling to an end is a fraction; one measured against a limit is a figure."""
+
+ def test_a_travelling_stage_carries_its_share(
+ self,
+ logic: SongExportLogic,
+ service: FakeExportService,
+ views: List[SongExportViewModel],
+ ) -> None:
+ service.deliver(ServiceStarted(total=NOTHING_MEASURED))
+ service.deliver(progress(ExportStage.WALKING, WALKED_TICKS, SONG_TICKS))
+ assert views[-1].progress == pytest.approx(WALKED_TICKS / SONG_TICKS)
+
+ def test_a_travelling_stage_states_no_figure(
+ self,
+ logic: SongExportLogic,
+ service: FakeExportService,
+ views: List[SongExportViewModel],
+ ) -> None:
+ service.deliver(ServiceStarted(total=NOTHING_MEASURED))
+ service.deliver(progress(ExportStage.WALKING, WALKED_TICKS, SONG_TICKS))
+ assert views[-1].figure == ""
+
+ def test_a_stage_measured_against_a_limit_spells_out_what_it_holds(
+ self,
+ logic: SongExportLogic,
+ service: FakeExportService,
+ views: List[SongExportViewModel],
+ ) -> None:
+ service.deliver(ServiceStarted(total=NOTHING_MEASURED))
+ service.deliver(progress(ExportStage.COMPRESSING, REACHED_SIZE, PROGRAM_AREA))
+ assert views[-1].figure == f"{REACHED_SIZE} of {PROGRAM_AREA} bytes"
+
+ def test_a_stage_measured_against_a_limit_carries_no_bar(
+ self,
+ logic: SongExportLogic,
+ service: FakeExportService,
+ views: List[SongExportViewModel],
+ ) -> None:
+ service.deliver(ServiceStarted(total=NOTHING_MEASURED))
+ service.deliver(progress(ExportStage.COMPRESSING, REACHED_SIZE, PROGRAM_AREA))
+ assert views[-1].traveling is False
+
+ def test_a_stage_with_nothing_to_measure_against_stands_at_the_start(
+ self,
+ logic: SongExportLogic,
+ service: FakeExportService,
+ views: List[SongExportViewModel],
+ ) -> None:
+ service.deliver(ServiceStarted(total=NOTHING_MEASURED))
+ service.deliver(progress(ExportStage.WALKING, NOTHING_MEASURED, NOTHING_MEASURED))
+ assert views[-1].progress == 0.0
+
+
+class TestStoppingARun:
+ """A stop reaches the service, and what the dialog says holds until the outcome arrives."""
+
+ def test_a_stop_reaches_the_service(
+ self,
+ logic: SongExportLogic,
+ service: FakeExportService,
+ ) -> None:
+ service.deliver(ServiceStarted(total=NOTHING_MEASURED))
+ logic.cancel()
+ assert service.cancels == 1
+
+ def test_a_stop_says_so(
+ self,
+ logic: SongExportLogic,
+ service: FakeExportService,
+ views: List[SongExportViewModel],
+ ) -> None:
+ service.deliver(ServiceStarted(total=NOTHING_MEASURED))
+ logic.cancel()
+ assert views[-1].figure == CANCELLING_LABEL
+
+ def test_a_report_arriving_after_a_stop_leaves_the_message_standing(
+ self,
+ logic: SongExportLogic,
+ service: FakeExportService,
+ views: List[SongExportViewModel],
+ ) -> None:
+ service.deliver(ServiceStarted(total=NOTHING_MEASURED))
+ logic.cancel()
+ service.deliver(progress(ExportStage.COMPRESSING, REACHED_SIZE, PROGRAM_AREA))
+ assert views[-1].figure == CANCELLING_LABEL
+
+ def test_a_stop_asked_of_nothing_reaches_no_service(
+ self,
+ logic: SongExportLogic,
+ service: FakeExportService,
+ ) -> None:
+ service.running = False
+ logic.cancel()
+ assert service.cancels == 0
+
+ def test_exiting_winds_a_running_export_down(
+ self,
+ logic: SongExportLogic,
+ service: FakeExportService,
+ ) -> None:
+ logic.cleanup()
+ assert service.shutdowns == 1
+
+
+class TestTheOutcomeThatCloses:
+ """Whatever the run answered with, the dialog hands the screen back."""
+
+ def test_a_finished_run_takes_the_dialog_off_screen(
+ self,
+ logic: SongExportLogic,
+ service: FakeExportService,
+ ) -> None:
+ closed: List[bool] = []
+ logic.on_finished = lambda: closed.append(True)
+ service.deliver(ServiceStarted(total=NOTHING_MEASURED))
+ service.deliver(finished())
+ assert closed == [True]
+
+ def test_a_canceled_run_takes_the_dialog_off_screen(
+ self,
+ logic: SongExportLogic,
+ service: FakeExportService,
+ ) -> None:
+ closed: List[bool] = []
+ logic.on_finished = lambda: closed.append(True)
+ service.deliver(ServiceStarted(total=NOTHING_MEASURED))
+ service.deliver(ServiceCanceled())
+ assert closed == [True]
+
+ def test_a_run_that_ended_holds_the_screen_no_longer(
+ self,
+ logic: SongExportLogic,
+ service: FakeExportService,
+ ) -> None:
+ service.deliver(ServiceStarted(total=NOTHING_MEASURED))
+ service.deliver(finished())
+ assert logic.is_active is False
+
+ def test_a_running_export_holds_the_screen(
+ self,
+ logic: SongExportLogic,
+ service: FakeExportService,
+ ) -> None:
+ service.deliver(ServiceStarted(total=NOTHING_MEASURED))
+ assert logic.is_active is True
+
+ def test_the_phase_returns_to_idle(
+ self,
+ logic: SongExportLogic,
+ service: FakeExportService,
+ views: List[SongExportViewModel],
+ ) -> None:
+ service.deliver(ServiceStarted(total=NOTHING_MEASURED))
+ service.deliver(finished())
+ assert views[-1].phase == ExportPhase.IDLE
diff --git a/tests/unit/sampletones_application/logic/history/test_action_labels.py b/tests/unit/sampletones_application/logic/history/test_action_labels.py
index ec94cc159..3ab630a7e 100644
--- a/tests/unit/sampletones_application/logic/history/test_action_labels.py
+++ b/tests/unit/sampletones_application/logic/history/test_action_labels.py
@@ -1,12 +1,10 @@
import pytest
from sampletones_application.categories.abstract import AbstractElement
-from sampletones_application.categories.elements.sequencer import SequencerHistoryElements
from sampletones_application.categories.hierarchy import Page, Panel, TextType
from sampletones_application.categories.manager import LanguageManager
from sampletones_application.logic.history.action import HistoryAction
from sampletones_application.paths import LANG_EN
-from sampletones_application.view_model.shared.history import HistoryDetailWord
@pytest.fixture
@@ -32,20 +30,3 @@ def test_every_action_resolves_a_label(
]
assert label
-
-
-class TestDetailWordLabels:
- @pytest.mark.parametrize("word", list(HistoryDetailWord), ids=lambda word: word.value)
- def test_every_word_resolves_a_label(
- self,
- word: HistoryDetailWord,
- language_manager: LanguageManager,
- ) -> None:
- label = language_manager[
- Page.SEQUENCER,
- Panel.HISTORY,
- TextType.LABEL,
- SequencerHistoryElements(word.value),
- ]
-
- assert label
diff --git a/tests/unit/sampletones_application/logic/history/test_fingerprint.py b/tests/unit/sampletones_application/logic/history/test_fingerprint.py
index a1f08ffdd..637ccdf17 100644
--- a/tests/unit/sampletones_application/logic/history/test_fingerprint.py
+++ b/tests/unit/sampletones_application/logic/history/test_fingerprint.py
@@ -160,8 +160,8 @@ def test_eviction_prunes_cache_to_retained_reconstructions(
with history.transaction(HistoryAction.ADD_SAMPLE):
sample = controller.add_sample(reconstruction_factory(), name="lead")
- with history.transaction(HistoryAction.REMOVE_SAMPLE):
- controller.remove_sample(sample.id)
+ with history.transaction(HistoryAction.REMOVE_VOICE):
+ controller.remove_voice(sample.id)
with history.transaction(HistoryAction.SET_TEMPO):
controller.set_tempo(150)
diff --git a/tests/unit/sampletones_application/logic/instruction/test_library_logic.py b/tests/unit/sampletones_application/logic/instruction/test_library_logic.py
index e1ac1669a..9a43e6715 100644
--- a/tests/unit/sampletones_application/logic/instruction/test_library_logic.py
+++ b/tests/unit/sampletones_application/logic/instruction/test_library_logic.py
@@ -27,13 +27,13 @@
INVALID_DATA_KEY: Final[str] = "instructions.library.message.status_invalid_data"
DESERIALIZATION_ERROR_KEY: Final[str] = "instructions.library.message.status_deserialization_error"
INCOMPATIBLE_VERSION_KEY: Final[str] = "instructions.library.template.incompatible_version_template"
-GENERATION_CANCELLED_KEY: Final[str] = "instructions.library.message.status_generation_cancelled"
+GENERATION_CANCELED_KEY: Final[str] = "instructions.library.message.status_generation_canceled"
TEXTS: Final[Dict[str, str]] = {
INCOMPATIBLE_VERSION_KEY: "got {} expected {}",
"instructions.library.message.status_saving": "saving",
"instructions.library.message.status_generation_failed": "failed",
- GENERATION_CANCELLED_KEY: "cancelled",
+ GENERATION_CANCELED_KEY: "canceled",
"instructions.library.label.generate_library_button": "Generate",
"instructions.library.label.regenerate_library_button": "Regenerate",
"instructions.library.template.library_loaded_template": "{} loaded.",
@@ -191,13 +191,13 @@ class TestGenerationEmits:
"""Every emit passes its status and progress explicitly, so the logic retains no
presentation state between emissions and each view model is complete on its own."""
- def test_cancelled_emits_the_language_managed_status(self) -> None:
+ def test_canceled_emits_the_language_managed_status(self) -> None:
logic = _generation_logic()
- logic._on_generation_progress(TaskStatus.CANCELLED, MagicMock())
+ logic._on_generation_progress(TaskStatus.CANCELED, MagicMock())
view_model = logic.on_view_changed.call_args.args[0]
- assert view_model.status_text == "cancelled"
+ assert view_model.status_text == "canceled"
def test_completed_emits_saving_at_full_progress(self) -> None:
logic = _generation_logic()
@@ -268,11 +268,11 @@ def test_update_status_reports_an_existing_unloaded_library(self) -> None:
assert view_model.generate_button_label == "Generate"
-class TestCancelledStatusLanguageKey:
- """The cancelled status resolves through ``LanguageManager`` at construction, so the language
+class TestCanceledStatusLanguageKey:
+ """The canceled status resolves through ``LanguageManager`` at construction, so the language
file must carry the key."""
- def test_cancelled_status_resolves_from_the_language_file(self) -> None:
+ def test_canceled_status_resolves_from_the_language_file(self) -> None:
language_manager = LanguageManager(LANG_EN)
- assert language_manager[GENERATION_CANCELLED_KEY]
+ assert language_manager[GENERATION_CANCELED_KEY]
diff --git a/tests/unit/sampletones_application/logic/main/test_converter.py b/tests/unit/sampletones_application/logic/main/test_converter.py
index 4fc8ddac3..f01e9aa72 100644
--- a/tests/unit/sampletones_application/logic/main/test_converter.py
+++ b/tests/unit/sampletones_application/logic/main/test_converter.py
@@ -1,18 +1,29 @@
from pathlib import Path
-from typing import Dict, Final
+from typing import Dict, Final, Optional
from unittest.mock import MagicMock, patch
import pytest
+from sampletones_application.constants.conversion import MAX_STEM_SOURCES, MIN_CHANNEL_CAP
from sampletones_application.logic.main.converter import (
ConversionSuccess,
ConverterLogic,
)
+from sampletones_application.services.conversion.result import (
+ ConversionItem,
+ ReconstructionStep,
+)
+from sampletones_application.services.result import ServiceProgress
from sampletones_application.view_model.main.converter import (
ACTIVE_PHASES,
ConversionPhase,
ConverterViewModel,
)
+from sampletones_core.configs import Config
+from sampletones_core.constants.enums import ChannelName, HierarchyMode
+from sampletones_core.reconstructions.converter import DirectoryConversion, GroupConversion
+from sampletones_core.reconstructions.reconstructor.stems.configs.config import StemsConfig
+from sampletones_core.reconstructions.stage import ReconstructionStage
from tests.suite.language import FakeLanguageManager
TEXTS: Final[Dict[str, str]] = {
@@ -20,13 +31,37 @@
"main.converter.label.convert_directory_button": "Convert directory",
"main.converter.label.cancel_button": "Cancel",
"main.converter.template.convert_label_template": "{}: {}",
+ "main.converter.template.progress_template": "Progress: {}/{} files",
+ "main.converter.template.single_progress_template": "Reconstructing {}...",
+ "main.converter.template.stage_template": " - {stage} {completed}/{total}",
+ "main.converter.message.stage_loading": "reading",
+ "main.converter.message.stage_matching": "matching",
+ "main.converter.message.stage_decoding": "decoding",
+ "main.converter.message.stage_rendering": "rendering",
+ "global.dialog.template.time_estimation": "",
}
+FRAMES: Final[int] = 1100
+
+
+def _config_writing_under(reconstructions_directory: Path) -> Config:
+ """A configuration whose reconstructions are written under ``reconstructions_directory``."""
+ config = Config()
+ general = config.general.model_copy(update={"reconstructions_directory": str(reconstructions_directory)})
+ return config.model_copy(update={"general": general})
+
@pytest.fixture
-def converter_logic() -> ConverterLogic:
+def converter_logic(tmp_path: Path) -> ConverterLogic:
+ """A converter reading a real configuration, so resolving where a run writes answers as it does live.
+
+ The configuration writes under the test's own directory, which keeps a target this converter
+ resolves within the test rather than in the reconstructions the developer holds.
+ """
+ reconstructions_directory = tmp_path / "reconstructions"
config_manager = MagicMock()
- config_manager.get_reconstructions_directory.return_value = Path("/tmp/reconstructions")
+ config_manager.config = _config_writing_under(reconstructions_directory)
+ config_manager.get_reconstructions_directory.return_value = reconstructions_directory
service = MagicMock()
service.is_running.return_value = False
scheduling = MagicMock(
@@ -55,9 +90,9 @@ def test_cancel_while_waiting_cancels_generation_and_finishes(
converter_logic: ConverterLogic,
) -> None:
cancel_generation = MagicMock()
- on_cancelled = MagicMock()
+ on_canceled = MagicMock()
converter_logic.cancel_library_generation = cancel_generation
- converter_logic.on_cancelled = on_cancelled
+ converter_logic.on_canceled = on_canceled
with patch("sampletones_application.logic.main.converter.CallbackQueue.add"):
converter_logic.start_conversion()
@@ -66,8 +101,8 @@ def test_cancel_while_waiting_cancels_generation_and_finishes(
converter_logic.cancel()
cancel_generation.assert_called_once()
- on_cancelled.assert_called_once()
- assert converter_logic._phase == ConversionPhase.CANCELLED
+ on_canceled.assert_called_once()
+ assert converter_logic._phase == ConversionPhase.CANCELED
def test_wait_loop_aborts_once_no_longer_waiting(
self,
@@ -101,14 +136,17 @@ def test_wait_poll_does_not_emit_a_zero_progress_view(
scheduled.assert_called_once()
-class TestNoGeneratorsGuard:
- """With no generators enabled there is nothing to reconstruct, so the conversion must not start."""
+class TestNoChannelsGuard:
+ """With no channels enabled there is nothing to reconstruct, so the conversion must not start."""
def test_no_generators_notifies_and_does_not_start(
self,
converter_logic: ConverterLogic,
) -> None:
- converter_logic._config_manager.config.generation.generators = []
+ config = converter_logic._config_manager.config
+ converter_logic._config_manager.config = config.model_copy(
+ update={"generation": config.generation.model_copy(update={"channels": []})}
+ )
on_no_generators = MagicMock()
converter_logic.on_no_generators = on_no_generators
@@ -119,6 +157,99 @@ def test_no_generators_notifies_and_does_not_start(
assert converter_logic._phase == ConversionPhase.IDLE
+class TestOverwriteGuard:
+ """A single conversion writes one named file, so a run that would replace one asks first.
+
+ A batch settles the question itself — it converts what is still to be written — so the
+ prompt reaches the reader for the single-file and stems runs alone.
+ """
+
+ @staticmethod
+ def _aimed_at(converter_logic: ConverterLogic, path: Path) -> Path:
+ """Points the converter at ``path`` and answers where its run would write."""
+ converter_logic.set_input_path(path)
+ config = converter_logic._config_manager.config
+ return GroupConversion(sources=(path,), stems=StemsConfig()).jobs(config)[0].output_path
+
+ @staticmethod
+ def _standing(target: Path) -> None:
+ target.parent.mkdir(parents=True, exist_ok=True)
+ target.touch()
+
+ def test_a_standing_target_is_put_to_the_reader_and_nothing_starts(
+ self,
+ converter_logic: ConverterLogic,
+ tmp_path: Path,
+ ) -> None:
+ source = tmp_path / "song.wav"
+ source.touch()
+ target = self._aimed_at(converter_logic, source)
+ self._standing(target)
+ on_target_exists = MagicMock()
+ converter_logic.on_target_exists = on_target_exists
+
+ with patch("sampletones_application.logic.main.converter.CallbackQueue.add"):
+ converter_logic.start_conversion()
+
+ on_target_exists.assert_called_once_with(target)
+ converter_logic.generate_library.assert_not_called()
+ assert converter_logic._phase == ConversionPhase.IDLE
+
+ def test_a_confirmed_run_goes_ahead(
+ self,
+ converter_logic: ConverterLogic,
+ tmp_path: Path,
+ ) -> None:
+ source = tmp_path / "song.wav"
+ source.touch()
+ self._standing(self._aimed_at(converter_logic, source))
+ on_target_exists = MagicMock()
+ converter_logic.on_target_exists = on_target_exists
+
+ with patch("sampletones_application.logic.main.converter.CallbackQueue.add"):
+ converter_logic.start_conversion(confirmed=True)
+
+ on_target_exists.assert_not_called()
+ converter_logic.generate_library.assert_called_once()
+ assert converter_logic._phase == ConversionPhase.WAITING
+
+ def test_a_target_still_to_be_written_starts_straight_away(
+ self,
+ converter_logic: ConverterLogic,
+ tmp_path: Path,
+ ) -> None:
+ source = tmp_path / "song.wav"
+ source.touch()
+ self._aimed_at(converter_logic, source)
+ on_target_exists = MagicMock()
+ converter_logic.on_target_exists = on_target_exists
+
+ with patch("sampletones_application.logic.main.converter.CallbackQueue.add"):
+ converter_logic.start_conversion()
+
+ on_target_exists.assert_not_called()
+ assert converter_logic._phase == ConversionPhase.WAITING
+
+ def test_a_batch_starts_without_asking(
+ self,
+ converter_logic: ConverterLogic,
+ tmp_path: Path,
+ ) -> None:
+ """The scan keeps every reconstruction already written, so a standing file stops nothing."""
+ sources = tmp_path / "sources"
+ sources.mkdir()
+ (sources / "song.wav").touch()
+ converter_logic.set_input_path(sources)
+ on_target_exists = MagicMock()
+ converter_logic.on_target_exists = on_target_exists
+
+ with patch("sampletones_application.logic.main.converter.CallbackQueue.add"):
+ converter_logic.start_conversion()
+
+ on_target_exists.assert_not_called()
+ assert converter_logic._phase == ConversionPhase.WAITING
+
+
class TestActivePhases:
"""``is_active`` reports a conversion occupying resources for every non-idle, non-terminal phase —
covering the WAITING preparation that runs before the service starts."""
@@ -137,7 +268,7 @@ def test_active_during_non_terminal_phases(
[
ConversionPhase.IDLE,
ConversionPhase.COMPLETED,
- ConversionPhase.CANCELLED,
+ ConversionPhase.CANCELED,
ConversionPhase.FAILED,
],
)
@@ -279,24 +410,46 @@ def test_unexpected_failure_propagates(
class TestConversionCompleteHandsOverOutcome:
- """A completed conversion tells its listener what was produced, so the follow-up load offer can
- target the single reconstruction (file) or the browser (directory)."""
+ """A completed conversion tells its listener what it wrote, so the follow-up offer can target
+ the single reconstruction or the folder holding a batch."""
- def test_success_carries_input_kind_and_output_path(
+ def test_success_carries_the_reconstructions_that_were_written(
self,
converter_logic: ConverterLogic,
) -> None:
on_success = MagicMock()
converter_logic.on_success = on_success
- converter_logic._is_file = True
- converter_logic._output_path = Path("/reconstructions/kick.rcn")
+ written = (Path("/reconstructions/kick.rcn"),)
- converter_logic._on_conversion_complete(Path("/reconstructions/kick.rcn"))
+ converter_logic._on_conversion_complete(written)
assert converter_logic._phase == ConversionPhase.COMPLETED
- on_success.assert_called_once_with(
- ConversionSuccess(is_file=True, output_path=Path("/reconstructions/kick.rcn"))
- )
+ on_success.assert_called_once_with(ConversionSuccess(written=written))
+
+ def test_one_written_reconstruction_becomes_the_displayed_output(
+ self,
+ converter_logic: ConverterLogic,
+ ) -> None:
+ written = (Path("/reconstructions/kick.rcn"),)
+
+ converter_logic._on_conversion_complete(written)
+
+ assert converter_logic._output_path == written[0]
+
+ def test_a_batch_loads_the_folder_and_one_file_loads_itself(
+ self,
+ converter_logic: ConverterLogic,
+ ) -> None:
+ converter_logic.on_load_file = MagicMock()
+ converter_logic.on_load_directory = MagicMock()
+
+ converter_logic._on_conversion_complete((Path("/reconstructions/kick.rcn"),))
+ converter_logic.handle_load_request()
+ converter_logic.on_load_file.assert_called_once_with(Path("/reconstructions/kick.rcn"))
+
+ converter_logic._on_conversion_complete((Path("/reconstructions/kick.rcn"), Path("/reconstructions/snare.rcn")))
+ converter_logic.handle_load_request()
+ converter_logic.on_load_directory.assert_called_once_with()
class TestFailureReturnsToIdle:
@@ -316,3 +469,354 @@ def test_failure_schedules_return_to_idle_and_reports(
scheduled.assert_called_once()
assert scheduled.call_args.args[0] == converter_logic.close
converter_logic.on_error.assert_called_once()
+
+
+class TestConversionPlan:
+ """What the converter asks the service to run: one reconstruction for a file, one per audio
+ file for a directory."""
+
+ def _prepare(self, converter_logic: ConverterLogic, input_path: Path, is_file: bool) -> Config:
+ config = Config()
+ converter_logic._config_manager.config = config
+ converter_logic._input_path = input_path
+ converter_logic._is_file = is_file
+ return config
+
+ def test_a_file_becomes_one_group_over_that_file(self, converter_logic: ConverterLogic) -> None:
+ config = self._prepare(converter_logic, Path("/audio/kick.wav"), is_file=True)
+
+ plan = converter_logic._conversion_plan(config, Path("/audio/kick.wav"))
+
+ assert isinstance(plan, GroupConversion)
+ assert plan.sources == (Path("/audio/kick.wav"),)
+
+ def test_a_directory_becomes_a_directory_conversion(self, converter_logic: ConverterLogic) -> None:
+ config = self._prepare(converter_logic, Path("/audio"), is_file=False)
+
+ plan = converter_logic._conversion_plan(config, Path("/audio"))
+
+ assert isinstance(plan, DirectoryConversion)
+ assert plan.directory == Path("/audio")
+
+ def test_the_setup_covers_every_enabled_channel(self, converter_logic: ConverterLogic) -> None:
+ """With no stems listed, one stem holds every channel the configuration enables."""
+ config = self._prepare(converter_logic, Path("/audio/kick.wav"), is_file=True)
+ channels = list(config.generation.channels)
+
+ plan = converter_logic._conversion_plan(config, Path("/audio/kick.wav"))
+
+ assert plan.stems == StemsConfig.single_entry(channels, channel_cap=len(channels))
+ assert plan.stems.covered_channels == frozenset(channels)
+
+ def test_starting_hands_the_plan_to_the_service(self, converter_logic: ConverterLogic) -> None:
+ config = self._prepare(converter_logic, Path("/audio/kick.wav"), is_file=True)
+ converter_logic._config_manager.config = config
+
+ converter_logic._start_conversion()
+
+ started_config, started_plan = converter_logic._service.start.call_args.args
+ assert started_config == config
+ assert isinstance(started_plan, GroupConversion)
+
+
+class TestStemsSetup:
+ """The rows a reader gathers, and the setup they turn into."""
+
+ def _with_config(self, converter_logic: ConverterLogic) -> Config:
+ config = Config()
+ converter_logic._config_manager.config = config
+ return config
+
+ def test_selecting_a_recording_in_stems_mode_adds_it(self, converter_logic: ConverterLogic) -> None:
+ self._with_config(converter_logic)
+ converter_logic.set_stems_mode(True)
+
+ converter_logic.select_source(Path("/audio/bass.wav"))
+ converter_logic.select_source(Path("/audio/lead.wav"))
+
+ assert converter_logic._source_paths == (Path("/audio/bass.wav"), Path("/audio/lead.wav"))
+
+ def test_adding_a_listed_recording_leaves_the_list_as_it_is(self, converter_logic: ConverterLogic) -> None:
+ self._with_config(converter_logic)
+ converter_logic.set_stems_mode(True)
+ converter_logic.add_sources([Path("/audio/bass.wav"), Path("/audio/lead.wav")])
+ converter_logic.isolate_source(Path("/audio/lead.wav"))
+
+ converter_logic.add_sources([Path("/audio/lead.wav")])
+
+ assert converter_logic._source_paths == (Path("/audio/bass.wav"), Path("/audio/lead.wav"))
+ assert converter_logic._levels.level_of(Path("/audio/lead.wav")) == 1
+
+ def test_the_list_stops_at_the_room_it_has(self, converter_logic: ConverterLogic) -> None:
+ self._with_config(converter_logic)
+ converter_logic.set_stems_mode(True)
+
+ converter_logic.add_sources([Path(f"/audio/{index}.wav") for index in range(MAX_STEM_SOURCES + 3)])
+
+ assert converter_logic.source_count == MAX_STEM_SOURCES
+
+ def test_removing_a_recording_takes_it_out(self, converter_logic: ConverterLogic) -> None:
+ self._with_config(converter_logic)
+ converter_logic.set_stems_mode(True)
+ converter_logic.add_sources([Path("/audio/a.wav"), Path("/audio/b.wav")])
+
+ converter_logic.remove_source(Path("/audio/a.wav"))
+
+ assert converter_logic._source_paths == (Path("/audio/b.wav"),)
+
+ def test_entering_stems_mode_carries_the_selected_file_in(self, converter_logic: ConverterLogic) -> None:
+ self._with_config(converter_logic)
+ converter_logic._input_path = Path("/audio/kick.wav")
+ converter_logic._is_file = True
+
+ converter_logic.set_stems_mode(True)
+
+ assert converter_logic._source_paths == (Path("/audio/kick.wav"),)
+
+ def test_leaving_stems_mode_keeps_the_first_recording(self, converter_logic: ConverterLogic) -> None:
+ self._with_config(converter_logic)
+ converter_logic.set_stems_mode(True)
+ converter_logic.add_sources([Path("/audio/a.wav"), Path("/audio/b.wav")])
+
+ converter_logic.set_stems_mode(False)
+
+ assert converter_logic._levels.paths == (Path("/audio/a.wav"),)
+
+ def test_a_stems_conversion_groups_every_listed_recording(self, converter_logic: ConverterLogic) -> None:
+ config = self._with_config(converter_logic)
+ converter_logic.set_stems_mode(True)
+ converter_logic.add_sources([Path("/audio/a.wav"), Path("/audio/b.wav")])
+
+ plan = converter_logic._conversion_plan(config, Path("/audio/a.wav"))
+
+ assert isinstance(plan, GroupConversion)
+ assert plan.sources == (Path("/audio/a.wav"), Path("/audio/b.wav"))
+ assert [entry.id for entry in plan.stems.entries] == [0, 1]
+
+ def test_the_rows_channels_and_levels_reach_the_setup(self, converter_logic: ConverterLogic) -> None:
+ config = self._with_config(converter_logic)
+ converter_logic.set_stems_mode(True)
+ converter_logic.add_sources([Path("/audio/a.wav"), Path("/audio/b.wav")])
+ converter_logic.set_source_channels(Path("/audio/a.wav"), frozenset({ChannelName.PULSE1}))
+ converter_logic.isolate_source(Path("/audio/b.wav"))
+
+ plan = converter_logic._conversion_plan(config, Path("/audio/a.wav"))
+
+ assert plan.stems.entries[0].channels == [ChannelName.PULSE1]
+ assert plan.stems.hierarchy.levels == [[0], [1]]
+
+ def test_a_recording_left_with_no_channel_takes_no_part(self, converter_logic: ConverterLogic) -> None:
+ config = self._with_config(converter_logic)
+ converter_logic.set_stems_mode(True)
+ converter_logic.add_sources([Path("/audio/a.wav"), Path("/audio/b.wav")])
+
+ converter_logic.set_source_channels(Path("/audio/a.wav"), frozenset())
+ plan = converter_logic._conversion_plan(config, Path("/audio/a.wav"))
+
+ assert converter_logic.source_count == 2
+ assert plan.sources == (Path("/audio/b.wav"),)
+ assert [entry.id for entry in plan.stems.entries] == [0]
+
+ def test_a_row_reports_the_level_it_landed_on(self, converter_logic: ConverterLogic) -> None:
+ config = self._with_config(converter_logic)
+ converter_logic.set_stems_mode(True)
+ converter_logic.add_sources([Path("/audio/a.wav"), Path("/audio/b.wav")])
+
+ converter_logic.move_source_to_new_level(Path("/audio/b.wav"), 0)
+ rows = converter_logic._stem_rows(config)
+
+ assert [(row.path.name, row.level, row.level_count) for row in rows] == [
+ ("b.wav", 0, 2),
+ ("a.wav", 1, 2),
+ ]
+
+ def test_the_cap_holds_within_the_channels_enabled(self, converter_logic: ConverterLogic) -> None:
+ config = self._with_config(converter_logic)
+ channels = list(config.generation.channels)
+
+ converter_logic.set_channel_cap(len(channels) + 5)
+
+ assert converter_logic._effective_channel_cap == len(channels)
+
+ def test_a_cap_below_one_is_refused(self, converter_logic: ConverterLogic) -> None:
+ self._with_config(converter_logic)
+
+ converter_logic.set_channel_cap(0)
+
+ assert converter_logic._effective_channel_cap == MIN_CHANNEL_CAP
+
+ def test_the_cap_reaches_a_classic_conversion_too(self, converter_logic: ConverterLogic) -> None:
+ """One recording per frame is a choice a reader makes for every conversion, batch included."""
+ config = self._with_config(converter_logic)
+ converter_logic._input_path = Path("/audio/kick.wav")
+ converter_logic._is_file = True
+ converter_logic.set_channel_cap(1)
+
+ plan = converter_logic._conversion_plan(config, Path("/audio/kick.wav"))
+
+ assert plan.stems.channel_cap == 1
+
+ def test_the_hierarchy_mode_reaches_the_setup(self, converter_logic: ConverterLogic) -> None:
+ config = self._with_config(converter_logic)
+ converter_logic.set_stems_mode(True)
+ converter_logic.add_sources([Path("/audio/a.wav")])
+
+ converter_logic.set_hierarchy_mode(HierarchyMode.STRICT)
+
+ plan = converter_logic._conversion_plan(config, Path("/audio/a.wav"))
+ assert plan.stems.hierarchy.mode == HierarchyMode.STRICT
+
+
+class TestStemsView:
+ """What the panel is told about the setup being built."""
+
+ def _emitted(self, converter_logic: ConverterLogic) -> ConverterViewModel:
+ return converter_logic.on_view_changed.call_args.args[0]
+
+ def test_the_rows_reach_the_view_in_list_order(self, converter_logic: ConverterLogic) -> None:
+ converter_logic._config_manager.config = Config()
+ converter_logic.set_stems_mode(True)
+ converter_logic.add_sources([Path("/audio/a.wav"), Path("/audio/b.wav")])
+
+ view_model = self._emitted(converter_logic)
+
+ assert [row.name for row in view_model.stem_sources] == ["a", "b"]
+ assert view_model.stems_mode is True
+ assert view_model.has_input is True
+
+ def test_a_row_shows_the_channels_it_may_take(self, converter_logic: ConverterLogic) -> None:
+ converter_logic._config_manager.config = Config()
+ converter_logic.set_stems_mode(True)
+ converter_logic.add_sources([Path("/audio/a.wav")])
+ converter_logic.set_source_channels(Path("/audio/a.wav"), frozenset({ChannelName.NOISE}))
+
+ assert self._emitted(converter_logic).stem_sources[0].channels == frozenset({ChannelName.NOISE})
+
+ def test_the_view_states_whether_another_recording_fits(self, converter_logic: ConverterLogic) -> None:
+ converter_logic._config_manager.config = Config()
+ converter_logic.set_stems_mode(True)
+ converter_logic.add_sources([Path(f"/audio/{index}.wav") for index in range(MAX_STEM_SOURCES)])
+
+ view_model = self._emitted(converter_logic)
+
+ assert view_model.source_count == MAX_STEM_SOURCES
+ assert view_model.can_add_source is False
+
+ def test_an_empty_stems_list_offers_nothing_to_convert(self, converter_logic: ConverterLogic) -> None:
+ converter_logic._config_manager.config = Config()
+ converter_logic.set_stems_mode(True)
+
+ view_model = self._emitted(converter_logic)
+
+ assert view_model.has_input is False
+ assert view_model.convert_button_enabled is False
+
+
+class TestProgressText:
+ """A batch counts the files it has written; a single job names the reconstruction it is making."""
+
+ @staticmethod
+ def _progress(
+ completed: int,
+ total: int,
+ item: Optional[ConversionItem] = None,
+ partial: float = 0.0,
+ ) -> ServiceProgress[ConversionItem]:
+ return ServiceProgress(
+ completed=completed,
+ total=total,
+ eta_seconds=None,
+ current_item=item,
+ partial=partial,
+ )
+
+ @staticmethod
+ def _status(converter_logic: ConverterLogic) -> str:
+ view_model = converter_logic.on_view_changed.call_args.args[0]
+ return str(view_model.status_text)
+
+ @staticmethod
+ def _bar(converter_logic: ConverterLogic) -> float:
+ view_model = converter_logic.on_view_changed.call_args.args[0]
+ return float(view_model.progress)
+
+ def test_a_batch_counts_its_files(self, converter_logic: ConverterLogic) -> None:
+ converter_logic._handle_progress_result(self._progress(2, 5))
+
+ assert self._status(converter_logic) == "Progress: 2/5 files"
+
+ def test_a_single_job_names_the_reconstruction_it_writes(self, converter_logic: ConverterLogic) -> None:
+ converter_logic._output_path = Path("/reconstructions/track.stn")
+
+ converter_logic._handle_progress_result(self._progress(0, 1))
+
+ assert self._status(converter_logic) == "Reconstructing track..."
+
+ def test_a_single_job_falls_back_to_the_selected_input(self, converter_logic: ConverterLogic) -> None:
+ converter_logic._output_path = None
+ converter_logic._input_path = Path("/audio/kick.wav")
+
+ converter_logic._handle_progress_result(self._progress(0, 1))
+
+ assert self._status(converter_logic) == "Reconstructing kick..."
+
+
+class TestASingleJobShowsItsProgress:
+ """One reconstruction is one job, so what moves its bar is the reconstruction's own account.
+
+ Without this a whole conversion reads as nothing done out of one file until the moment it is
+ written, which tells a reader watching it nothing at all.
+ """
+
+ @staticmethod
+ def _item(stage: ReconstructionStage, completed: int) -> ConversionItem:
+ return ConversionItem(
+ source=Path("/audio/kick.wav"),
+ step=ReconstructionStep(stage=stage, completed=completed, total=FRAMES),
+ )
+
+ def test_the_bar_reads_the_work_under_way(self, converter_logic: ConverterLogic) -> None:
+ converter_logic._handle_progress_result(
+ TestProgressText._progress(0, 1, item=self._item(ReconstructionStage.MATCHING, 412), partial=0.35)
+ )
+
+ assert TestProgressText._bar(converter_logic) == pytest.approx(0.35)
+
+ def test_the_status_names_the_stage_and_its_counts(self, converter_logic: ConverterLogic) -> None:
+ converter_logic._input_path = Path("/audio/kick.wav")
+ converter_logic._output_path = None
+
+ converter_logic._handle_progress_result(
+ TestProgressText._progress(0, 1, item=self._item(ReconstructionStage.MATCHING, 412), partial=0.35)
+ )
+
+ assert TestProgressText._status(converter_logic) == "Reconstructing kick... - matching 412/1100"
+
+ def test_a_run_yet_to_say_anything_still_names_its_recording(
+ self,
+ converter_logic: ConverterLogic,
+ ) -> None:
+ converter_logic._output_path = None
+ converter_logic._input_path = Path("/audio/kick.wav")
+ item = ConversionItem(source=Path("/audio/kick.wav"))
+
+ converter_logic._handle_progress_result(TestProgressText._progress(0, 1, item=item))
+
+ assert TestProgressText._status(converter_logic) == "Reconstructing kick..."
+ assert TestProgressText._bar(converter_logic) == pytest.approx(0.0)
+
+ def test_a_batch_counts_the_reconstruction_under_way_toward_its_files(
+ self,
+ converter_logic: ConverterLogic,
+ ) -> None:
+ converter_logic._handle_progress_result(
+ TestProgressText._progress(
+ 2,
+ 5,
+ item=self._item(ReconstructionStage.RENDERING, FRAMES),
+ partial=0.5,
+ )
+ )
+
+ assert TestProgressText._bar(converter_logic) == pytest.approx(0.5)
+ assert TestProgressText._status(converter_logic) == "Progress: 2/5 files - rendering 1100/1100"
diff --git a/tests/unit/sampletones_application/logic/main/test_stems.py b/tests/unit/sampletones_application/logic/main/test_stems.py
new file mode 100644
index 000000000..3a44902cb
--- /dev/null
+++ b/tests/unit/sampletones_application/logic/main/test_stems.py
@@ -0,0 +1,218 @@
+from pathlib import Path
+from typing import FrozenSet, List, Sequence
+
+import pytest
+
+from sampletones_application.logic.main.stems import (
+ StemLevels,
+ StemSource,
+ derive_conversion_setup,
+ effective_channels,
+)
+from sampletones_core.constants.enums import ChannelName, HierarchyMode
+
+ENABLED: List[ChannelName] = [ChannelName.PULSE1, ChannelName.TRIANGLE, ChannelName.NOISE]
+
+
+def _path(name: str) -> Path:
+ return Path(f"/audio/{name}.wav")
+
+
+def _source(name: str, channels: FrozenSet[ChannelName] = frozenset(ENABLED)) -> StemSource:
+ return StemSource(path=_path(name), channels=channels)
+
+
+def _levels(*names: Sequence[str]) -> StemLevels:
+ return StemLevels.of([[_source(name) for name in level] for level in names])
+
+
+def _shape(levels: StemLevels) -> List[List[str]]:
+ return [[source.path.stem for source in level] for level in levels.levels]
+
+
+class TestEffectiveChannels:
+ def test_a_source_keeps_the_channels_still_enabled(self) -> None:
+ source = _source("lead", frozenset({ChannelName.PULSE1, ChannelName.PULSE2}))
+ assert effective_channels(source, ENABLED) == [ChannelName.PULSE1]
+
+ def test_a_source_holding_no_enabled_channel_takes_no_part(self) -> None:
+ source = _source("lead", frozenset({ChannelName.PULSE2}))
+ assert effective_channels(source, ENABLED) == []
+
+ def test_channels_follow_the_order_the_run_enables_them(self) -> None:
+ source = _source("lead", frozenset({ChannelName.NOISE, ChannelName.PULSE1}))
+ assert effective_channels(source, ENABLED) == [ChannelName.PULSE1, ChannelName.NOISE]
+
+
+class TestGathering:
+ """The list a reader builds: recordings arrive on the first level and leave without a trace."""
+
+ def test_the_first_recording_opens_a_level(self) -> None:
+ assert _shape(StemLevels().add(_source("bass"))) == [["bass"]]
+
+ def test_further_recordings_join_the_first_level(self) -> None:
+ levels = StemLevels().add(_source("bass")).add(_source("lead"))
+ assert _shape(levels) == [["bass", "lead"]]
+
+ def test_a_recording_already_gathered_changes_nothing(self) -> None:
+ levels = _levels(["bass"]).add(_source("bass"))
+ assert _shape(levels) == [["bass"]]
+
+ def test_removing_the_last_of_a_level_takes_the_level_with_it(self) -> None:
+ assert _shape(_levels(["bass"], ["lead"]).remove(_path("bass"))) == [["lead"]]
+
+ def test_leaving_stems_mode_keeps_the_recording_that_picks_first(self) -> None:
+ assert _shape(_levels(["bass", "lead"], ["pad"]).keep_first()) == [["bass"]]
+
+ def test_a_row_states_where_it_stands(self) -> None:
+ levels = _levels(["bass", "lead"], ["pad"])
+ assert (levels.level_of(_path("lead")), levels.position_of(_path("lead"))) == (0, 1)
+
+ def test_asking_after_a_recording_that_was_never_gathered_fails(self) -> None:
+ with pytest.raises(KeyError):
+ _levels(["bass"]).level_of(_path("lead"))
+
+
+class TestMovesWithinALevel:
+ """Position among peers settles which of two equal-cost choices picks first."""
+
+ def test_a_recording_moves_past_its_neighbor(self) -> None:
+ assert _shape(_levels(["bass", "lead"]).move_within_level(_path("lead"), -1)) == [["lead", "bass"]]
+
+ def test_a_move_off_the_end_of_a_level_changes_nothing(self) -> None:
+ levels = _levels(["bass", "lead"])
+ assert _shape(levels.move_within_level(_path("bass"), -1)) == _shape(levels)
+
+
+class TestMovesBetweenLevels:
+ def test_a_recording_joins_the_level_below(self) -> None:
+ assert _shape(_levels(["bass"], ["lead"]).join_level(_path("bass"), 1)) == [["lead", "bass"]]
+
+ def test_a_recording_joins_the_level_above(self) -> None:
+ assert _shape(_levels(["bass"], ["lead"]).join_level(_path("lead"), -1)) == [["bass", "lead"]]
+
+ def test_joining_past_the_last_level_changes_nothing(self) -> None:
+ levels = _levels(["bass"], ["lead"])
+ assert _shape(levels.join_level(_path("lead"), 1)) == _shape(levels)
+
+ def test_a_recording_takes_a_level_of_its_own_after_the_one_it_shared(self) -> None:
+ assert _shape(_levels(["bass", "lead"], ["pad"]).isolate(_path("bass"))) == [["lead"], ["bass"], ["pad"]]
+
+ def test_a_recording_already_alone_stays_where_it_is(self) -> None:
+ levels = _levels(["bass"], ["lead"])
+ assert _shape(levels.isolate(_path("bass"))) == _shape(levels)
+
+
+class TestDropOntoARow:
+ def test_the_dragged_recording_takes_the_place_it_was_dropped_on(self) -> None:
+ assert _shape(_levels(["bass"], ["lead", "pad"]).move_onto(_path("bass"), _path("pad"))) == [
+ ["lead", "bass", "pad"]
+ ]
+
+ def test_dropping_a_recording_on_itself_changes_nothing(self) -> None:
+ levels = _levels(["bass", "lead"])
+ assert _shape(levels.move_onto(_path("bass"), _path("bass"))) == _shape(levels)
+
+
+class TestDropOntoAStrip:
+ """A strip is the gap between two bands, counted from the one above the first level."""
+
+ @pytest.mark.parametrize(
+ ("position", "expected"),
+ [
+ (0, [["bass"], ["lead"], ["pad"]]),
+ (1, [["bass"], ["lead"], ["pad"]]),
+ (2, [["lead"], ["bass"], ["pad"]]),
+ (3, [["lead"], ["pad"], ["bass"]]),
+ ],
+ )
+ def test_a_lone_recording_lands_in_the_slot_it_was_dropped_in(
+ self,
+ position: int,
+ expected: List[List[str]],
+ ) -> None:
+ levels = _levels(["bass"], ["lead"], ["pad"])
+ assert _shape(levels.move_to_new_level(_path("bass"), position)) == expected
+
+ @pytest.mark.parametrize(
+ ("position", "expected"),
+ [
+ (0, [["bass"], ["lead"], ["pad"]]),
+ (1, [["lead"], ["bass"], ["pad"]]),
+ (2, [["lead"], ["pad"], ["bass"]]),
+ ],
+ )
+ def test_a_recording_leaving_its_peers_opens_a_level(
+ self,
+ position: int,
+ expected: List[List[str]],
+ ) -> None:
+ levels = _levels(["bass", "lead"], ["pad"])
+ assert _shape(levels.move_to_new_level(_path("bass"), position)) == expected
+
+
+class TestDeriveConversionSetup:
+ def test_a_recordings_position_is_its_stem_id(self) -> None:
+ setup = derive_conversion_setup(
+ _levels(["a", "b"]),
+ ENABLED,
+ channel_cap=1,
+ hierarchy_mode=HierarchyMode.STRICT,
+ )
+
+ assert [entry.id for entry in setup.stems.entries] == [0, 1]
+
+ def test_recordings_sharing_a_level_pick_together(self) -> None:
+ setup = derive_conversion_setup(
+ _levels(["a", "c"], ["b"]),
+ ENABLED,
+ channel_cap=1,
+ hierarchy_mode=HierarchyMode.STRICT,
+ )
+
+ assert setup.stems.hierarchy.levels == [[0, 1], [2]]
+
+ def test_the_mix_lists_the_recordings_in_entry_order(self) -> None:
+ setup = derive_conversion_setup(
+ _levels(["a"], ["b"]),
+ ENABLED,
+ channel_cap=1,
+ hierarchy_mode=HierarchyMode.ROUND_ROBIN,
+ )
+
+ assert setup.sources == (_path("a"), _path("b"))
+
+ def test_a_recording_holding_no_enabled_channel_reaches_neither_the_mix_nor_the_entries(self) -> None:
+ levels = StemLevels.of([[_source("a"), _source("silent", frozenset())], [_source("b")]])
+
+ setup = derive_conversion_setup(
+ levels,
+ ENABLED,
+ channel_cap=1,
+ hierarchy_mode=HierarchyMode.STRICT,
+ )
+
+ assert setup.sources == (_path("a"), _path("b"))
+ assert setup.stems.hierarchy.levels == [[0], [1]]
+
+ def test_a_level_left_with_nobody_taking_part_drops_out(self) -> None:
+ levels = StemLevels.of([[_source("silent", frozenset())], [_source("b")]])
+
+ setup = derive_conversion_setup(
+ levels,
+ ENABLED,
+ channel_cap=1,
+ hierarchy_mode=HierarchyMode.STRICT,
+ )
+
+ assert setup.stems.hierarchy.levels == [[0]]
+
+ def test_the_cap_and_the_mode_travel_with_the_setup(self) -> None:
+ setup = derive_conversion_setup(
+ _levels(["a"]),
+ ENABLED,
+ channel_cap=2,
+ hierarchy_mode=HierarchyMode.ROUND_ROBIN,
+ )
+
+ assert (setup.stems.channel_cap, setup.stems.hierarchy.mode) == (2, HierarchyMode.ROUND_ROBIN)
diff --git a/tests/unit/sampletones_application/logic/project/test_controller.py b/tests/unit/sampletones_application/logic/project/test_controller.py
index 54dfae4de..67b2966d7 100644
--- a/tests/unit/sampletones_application/logic/project/test_controller.py
+++ b/tests/unit/sampletones_application/logic/project/test_controller.py
@@ -1,15 +1,20 @@
from pathlib import Path
from typing import Callable, List
+from unittest.mock import Mock
import numpy as np
import pytest
from sampletones_application.logic.project.controller import ProjectController
from sampletones_application.logic.project.manager import ProjectManager
-from sampletones_core.constants.enums import GeneratorName
+from sampletones_core.constants.enums import ChannelName, FeatureKey
+from sampletones_core.features.envelope import Envelope
from sampletones_core.instructions import PulseInstruction
from sampletones_core.project import ProjectContainer
-from sampletones_core.project.instruments.instrument import Instrument
+from sampletones_core.project.voices.creation import new_instrument
+from sampletones_core.project.voices.envelopes import InstrumentEnvelopes
+from sampletones_core.project.voices.instrument import Instrument
+from sampletones_core.project.voices.note_on import NoteOn
from sampletones_core.reconstructions import Reconstruction
@@ -59,13 +64,13 @@ def test_add_sample_appends_and_emits(
) -> None:
controller = _controller()
emitted: List[str] = []
- controller.on_samples_changed = lambda: emitted.append("samples")
+ controller.on_voices_changed = lambda: emitted.append("voices")
sample = controller.add_sample(reconstruction_factory(), name="lead")
- assert list(controller.project.samples) == [sample]
- assert controller.project.sample(sample.id) is sample
- assert emitted == ["samples"]
+ assert list(controller.project.voices) == [sample]
+ assert controller.project.voice(sample.id) is sample
+ assert emitted == ["voices"]
def test_add_sample_detaches_source_but_keeps_object_identity(
self,
@@ -73,12 +78,12 @@ def test_add_sample_detaches_source_but_keeps_object_identity(
) -> None:
controller = _controller()
reconstruction = reconstruction_factory()
- assert reconstruction.audio_filepath is not None
+ assert reconstruction.audio_filepath
sample = controller.add_sample(reconstruction, name="lead")
assert sample.reconstruction is reconstruction
- assert sample.reconstruction.audio_filepath is None
+ assert sample.reconstruction.audio_filepath == ()
def test_remove_sample_purges_row_references(
self,
@@ -87,19 +92,19 @@ def test_remove_sample_purges_row_references(
controller = _controller()
sample = controller.add_sample(reconstruction_factory(), name="lead")
song = controller.project.song
- pattern_id = song.order[0][GeneratorName.PULSE1]
+ pattern_id = song.order[0][ChannelName.PULSE1]
controller.set_row(
- GeneratorName.PULSE1,
+ ChannelName.PULSE1,
pattern_id,
0,
- command=Instrument(sample_id=sample.id, generator_name=GeneratorName.PULSE1),
+ command=NoteOn(voice_id=sample.id),
volume=15,
)
- controller.remove_sample(sample.id)
+ controller.remove_voice(sample.id)
- assert controller.project.sample(sample.id) is None
- assert song.pattern(GeneratorName.PULSE1, pattern_id).rows[0].command is None
+ assert controller.project.voice(sample.id) is None
+ assert song.pattern(ChannelName.PULSE1, pattern_id).rows[0].command is None
def test_is_sample_used_reflects_pattern_references(
self,
@@ -107,21 +112,18 @@ def test_is_sample_used_reflects_pattern_references(
) -> None:
controller = _controller()
sample = controller.add_sample(reconstruction_factory(), name="lead")
- pattern_id = controller.project.song.order[0][GeneratorName.PULSE1]
+ pattern_id = controller.project.song.order[0][ChannelName.PULSE1]
- assert controller.is_sample_used(sample.id) is False
+ assert controller.is_voice_used(sample.id) is False
controller.set_row(
- GeneratorName.PULSE1,
+ ChannelName.PULSE1,
pattern_id,
0,
- command=Instrument(
- sample_id=sample.id,
- generator_name=GeneratorName.PULSE1,
- ),
+ command=NoteOn(voice_id=sample.id),
)
- assert controller.is_sample_used(sample.id) is True
+ assert controller.is_voice_used(sample.id) is True
def test_move_sample_reorders_pool(
self,
@@ -132,14 +134,14 @@ def test_move_sample_reorders_pool(
controller.add_sample(reconstruction_factory(), name="second")
controller.add_sample(reconstruction_factory(), name="third")
- controller.move_sample(first.id, 2)
+ controller.move_voice(first.id, 2)
- assert [sample.name for sample in controller.project.samples] == [
+ assert [sample.name for sample in controller.project.voices] == [
"second",
"third",
"first",
]
- assert controller.project.samples.get_index(first.id) == 2
+ assert controller.project.voices.get_index(first.id) == 2
def test_move_sample_preserves_row_references(
self,
@@ -149,22 +151,19 @@ def test_move_sample_preserves_row_references(
sample = controller.add_sample(reconstruction_factory(), name="lead")
controller.add_sample(reconstruction_factory(), name="pad")
song = controller.project.song
- pattern_id = song.order[0][GeneratorName.PULSE1]
+ pattern_id = song.order[0][ChannelName.PULSE1]
controller.set_row(
- GeneratorName.PULSE1,
+ ChannelName.PULSE1,
pattern_id,
0,
- command=Instrument(
- sample_id=sample.id,
- generator_name=GeneratorName.PULSE1,
- ),
+ command=NoteOn(voice_id=sample.id),
)
- controller.move_sample(sample.id, 1)
+ controller.move_voice(sample.id, 1)
- row = song.pattern(GeneratorName.PULSE1, pattern_id).rows[0]
+ row = song.pattern(ChannelName.PULSE1, pattern_id).rows[0]
assert row.command is not None
- assert row.command.sample_id == sample.id
+ assert row.command.voice_id == sample.id
def test_move_sample_emits_samples_and_song_changes(
self, reconstruction_factory: Callable[[], Reconstruction]
@@ -173,12 +172,12 @@ def test_move_sample_emits_samples_and_song_changes(
sample = controller.add_sample(reconstruction_factory(), name="lead")
controller.add_sample(reconstruction_factory(), name="pad")
emitted: List[str] = []
- controller.on_samples_changed = lambda: emitted.append("samples")
+ controller.on_voices_changed = lambda: emitted.append("voices")
controller.on_song_changed = lambda: emitted.append("song")
- controller.move_sample(sample.id, 1)
+ controller.move_voice(sample.id, 1)
- assert "samples" in emitted
+ assert "voices" in emitted
assert "song" in emitted
def test_duplicate_sample_appends_independent_copy(
@@ -187,16 +186,16 @@ def test_duplicate_sample_appends_independent_copy(
controller = _controller()
source = controller.add_sample(reconstruction_factory(), name="lead")
- clone = controller.duplicate_sample(source.id)
+ clone = controller.duplicate_voice(source.id)
assert clone.id != source.id
assert clone.name == source.name
assert clone.reconstruction is not source.reconstruction
- assert [sample.name for sample in controller.project.samples] == [
+ assert [sample.name for sample in controller.project.voices] == [
"lead",
"lead",
]
- assert controller.project.samples.get_index(clone.id) == 1
+ assert controller.project.voices.get_index(clone.id) == 1
def test_duplicate_sample_emits_samples_change(
self,
@@ -205,11 +204,11 @@ def test_duplicate_sample_emits_samples_change(
controller = _controller()
source = controller.add_sample(reconstruction_factory(), name="lead")
emitted: List[str] = []
- controller.on_samples_changed = lambda: emitted.append("samples")
+ controller.on_voices_changed = lambda: emitted.append("voices")
- controller.duplicate_sample(source.id)
+ controller.duplicate_voice(source.id)
- assert emitted == ["samples"]
+ assert emitted == ["voices"]
def test_replace_sample_reconstruction_swaps_content_and_keeps_identity(
self,
@@ -223,7 +222,7 @@ def test_replace_sample_reconstruction_swaps_content_and_keeps_identity(
assert sample.reconstruction is replacement
assert sample.name == "lead"
- assert controller.project.sample(sample.id) is sample
+ assert controller.project.voice(sample.id) is sample
def test_replace_sample_reconstruction_detaches_source(
self,
@@ -232,11 +231,11 @@ def test_replace_sample_reconstruction_detaches_source(
controller = _controller()
sample = controller.add_sample(reconstruction_factory(), name="lead")
replacement = reconstruction_factory()
- assert replacement.audio_filepath is not None
+ assert replacement.audio_filepath
controller.replace_sample_reconstruction(sample.id, replacement)
- assert replacement.audio_filepath is None
+ assert replacement.audio_filepath == ()
def test_replace_sample_reconstruction_preserves_row_references(
self,
@@ -245,15 +244,12 @@ def test_replace_sample_reconstruction_preserves_row_references(
controller = _controller()
sample = controller.add_sample(reconstruction_factory(), name="lead")
song = controller.project.song
- pattern_id = song.order[0][GeneratorName.PULSE1]
+ pattern_id = song.order[0][ChannelName.PULSE1]
controller.set_row(
- GeneratorName.PULSE1,
+ ChannelName.PULSE1,
pattern_id,
0,
- command=Instrument(
- sample_id=sample.id,
- generator_name=GeneratorName.PULSE1,
- ),
+ command=NoteOn(voice_id=sample.id),
)
controller.replace_sample_reconstruction(
@@ -261,9 +257,9 @@ def test_replace_sample_reconstruction_preserves_row_references(
reconstruction_factory(),
)
- row = song.pattern(GeneratorName.PULSE1, pattern_id).rows[0]
+ row = song.pattern(ChannelName.PULSE1, pattern_id).rows[0]
assert row.command is not None
- assert row.command.sample_id == sample.id
+ assert row.command.voice_id == sample.id
def test_replace_sample_reconstruction_emits_samples_and_song_changes(
self,
@@ -272,7 +268,7 @@ def test_replace_sample_reconstruction_emits_samples_and_song_changes(
controller = _controller()
sample = controller.add_sample(reconstruction_factory(), name="lead")
emitted: List[str] = []
- controller.on_samples_changed = lambda: emitted.append("samples")
+ controller.on_voices_changed = lambda: emitted.append("voices")
controller.on_song_changed = lambda: emitted.append("song")
controller.replace_sample_reconstruction(
@@ -280,7 +276,7 @@ def test_replace_sample_reconstruction_emits_samples_and_song_changes(
reconstruction_factory(),
)
- assert "samples" in emitted
+ assert "voices" in emitted
assert "song" in emitted
@@ -292,23 +288,20 @@ def test_set_row_replaces_row(
controller = _controller()
sample = controller.add_sample(reconstruction_factory(), name="lead")
song = controller.project.song
- pattern_id = song.order[0][GeneratorName.PULSE1]
+ pattern_id = song.order[0][ChannelName.PULSE1]
controller.set_row(
- GeneratorName.PULSE1,
+ ChannelName.PULSE1,
pattern_id,
2,
- command=Instrument(
- sample_id=sample.id,
- generator_name=GeneratorName.PULSE1,
- ),
+ command=NoteOn(voice_id=sample.id),
transpose=0,
volume=10,
)
- row = song.pattern(GeneratorName.PULSE1, pattern_id).rows[2]
+ row = song.pattern(ChannelName.PULSE1, pattern_id).rows[2]
assert row.command is not None
- assert row.command.sample_id == sample.id
+ assert row.command.voice_id == sample.id
assert row.transpose == 0
assert row.volume == 10
@@ -316,28 +309,28 @@ def test_remove_pattern_drops_from_pool_and_nulls_order_references(self) -> None
controller = _controller()
song = controller.project.song
- pattern_index = controller.add_pattern(GeneratorName.TRIANGLE)
+ pattern_index = controller.add_pattern(ChannelName.TRIANGLE)
controller.append_frame()
- controller.set_order_entry(GeneratorName.TRIANGLE, 1, pattern_index)
- assert song.order[1][GeneratorName.TRIANGLE] == pattern_index
- assert song.pattern(GeneratorName.TRIANGLE, pattern_index) is not None
+ controller.set_order_entry(ChannelName.TRIANGLE, 1, pattern_index)
+ assert song.order[1][ChannelName.TRIANGLE] == pattern_index
+ assert song.pattern(ChannelName.TRIANGLE, pattern_index) is not None
- controller.remove_pattern(GeneratorName.TRIANGLE, pattern_index)
- assert song.pattern(GeneratorName.TRIANGLE, pattern_index) is None
- assert song.order[1][GeneratorName.TRIANGLE] is None
+ controller.remove_pattern(ChannelName.TRIANGLE, pattern_index)
+ assert song.pattern(ChannelName.TRIANGLE, pattern_index) is None
+ assert song.order[1][ChannelName.TRIANGLE] is None
def test_move_frame_swaps_order_positions(self) -> None:
controller = _controller()
song = controller.project.song
- first_index = song.order[0][GeneratorName.PULSE2]
- second_index = controller.add_pattern(GeneratorName.PULSE2)
+ first_index = song.order[0][ChannelName.PULSE2]
+ second_index = controller.add_pattern(ChannelName.PULSE2)
controller.append_frame()
- controller.set_order_entry(GeneratorName.PULSE2, 1, second_index)
+ controller.set_order_entry(ChannelName.PULSE2, 1, second_index)
controller.move_frame(0, 1)
- assert song.order[0][GeneratorName.PULSE2] == second_index
- assert song.order[1][GeneratorName.PULSE2] == first_index
+ assert song.order[0][ChannelName.PULSE2] == second_index
+ assert song.order[1][ChannelName.PULSE2] == first_index
class TestPersistenceRoundTrip:
@@ -349,15 +342,12 @@ def test_controller_edits_survive_save_load(
controller.set_tempo(96)
sample = controller.add_sample(reconstruction_factory(), name="lead")
song = controller.project.song
- pattern_id = song.order[0][GeneratorName.PULSE1]
+ pattern_id = song.order[0][ChannelName.PULSE1]
controller.set_row(
- GeneratorName.PULSE1,
+ ChannelName.PULSE1,
pattern_id,
0,
- command=Instrument(
- sample_id=sample.id,
- generator_name=GeneratorName.PULSE1,
- ),
+ command=NoteOn(voice_id=sample.id),
volume=12,
)
@@ -367,10 +357,10 @@ def test_controller_edits_survive_save_load(
assert loaded.info.title == "Round"
assert loaded.settings.tempo == 96
- assert [stored.name for stored in loaded.samples] == ["lead"]
- loaded_row = loaded.song.pattern(GeneratorName.PULSE1, pattern_id).rows[0]
+ assert [stored.name for stored in loaded.voices] == ["lead"]
+ loaded_row = loaded.song.pattern(ChannelName.PULSE1, pattern_id).rows[0]
assert loaded_row.command is not None
- assert loaded_row.command.sample_id == sample.id
+ assert loaded_row.command.voice_id == sample.id
assert loaded_row.volume == 12
@@ -379,19 +369,19 @@ def test_order_length_returns_number_of_frames(self) -> None:
controller = _controller()
assert controller.order_length >= 1
- def test_sample_count_tracks_the_pool(
+ def test_voice_count_tracks_the_pool(
self,
reconstruction_factory: Callable[[], Reconstruction],
) -> None:
controller = _controller()
- assert controller.sample_count == 0
+ assert controller.voice_count == 0
sample = controller.add_sample(reconstruction_factory(), name="lead")
controller.add_sample(reconstruction_factory(), name="pad")
- assert controller.sample_count == 2
+ assert controller.voice_count == 2
- controller.remove_sample(sample.id)
- assert controller.sample_count == 1
+ controller.remove_voice(sample.id)
+ assert controller.voice_count == 1
def test_is_dirty_false_initially(self) -> None:
controller = _controller()
@@ -503,32 +493,96 @@ def test_set_sample_rate_updates_settings(self) -> None:
assert fired == ["settings"]
-class TestSampleLoop:
- def test_set_sample_loop_toggles_loop_flag(
+class TestInstruments:
+ def test_add_instrument_appends_the_voice_it_is_given(self) -> None:
+ controller = _controller()
+ instrument = new_instrument("lead")
+
+ added = controller.add_instrument(instrument)
+
+ assert added is instrument
+ assert controller.project.voice(instrument.id) is instrument
+
+ def test_writing_an_envelope_reaches_the_frames_the_instrument_sounds(self) -> None:
+ controller = _controller()
+ instrument = controller.add_instrument(new_instrument("lead"))
+
+ controller.set_instrument_envelope(instrument.id, FeatureKey.VOLUME, Envelope(items=(15, 10)))
+
+ assert instrument.envelopes.volume.items == (15, 10)
+ assert len(instrument.instructions(ChannelName.PULSE1)) == 2
+
+ def test_emptying_an_envelope_leaves_the_dimension_to_the_channel(self) -> None:
+ controller = _controller()
+ instrument = controller.add_instrument(new_instrument("lead"))
+ controller.set_instrument_envelope(instrument.id, FeatureKey.VOLUME, Envelope(items=(15,)))
+
+ controller.set_instrument_envelope(instrument.id, FeatureKey.VOLUME, Envelope(items=()))
+
+ assert FeatureKey.VOLUME in instrument.held_features(ChannelName.PULSE1)
+
+ def test_the_pitch_an_instrument_states_is_the_one_its_frames_are_built_at(self) -> None:
+ """The stored value is the origin an export reads; a row moves the voice off it."""
+ controller = _controller()
+ instrument = controller.add_instrument(
+ Instrument(
+ name="lead",
+ envelopes=InstrumentEnvelopes(volume=Envelope(items=(15,))),
+ initial_pitch=48,
+ initial_period=3,
+ )
+ )
+
+ assert instrument.reference(ChannelName.PULSE1) == 48
+ assert instrument.reference(ChannelName.NOISE) == 3
+ first = instrument.instructions(ChannelName.PULSE1)[0]
+ assert isinstance(first, PulseInstruction)
+ assert first.pitch == 48
+
+ def test_a_sample_takes_no_instrument_edit(
self,
reconstruction_factory: Callable[[], Reconstruction],
) -> None:
controller = _controller()
- sample = controller.add_sample(reconstruction_factory(), name="lead")
- controller.set_sample_loop(sample.id, True)
- assert controller.project.sample(sample.id).loop is True
+ sample = controller.add_sample(reconstruction_factory(), name="bass")
+
+ with pytest.raises(TypeError):
+ controller.set_instrument_envelope(sample.id, FeatureKey.VOLUME, Envelope(items=(15,)))
+
+ def test_an_instrument_takes_no_reconstruction(self) -> None:
+ controller = _controller()
+ instrument = controller.add_instrument(new_instrument("lead"))
+
+ with pytest.raises(TypeError):
+ controller.replace_sample_reconstruction(instrument.id, Mock())
+
+ def test_an_instrument_duplicates_into_a_voice_of_its_own(self) -> None:
+ controller = _controller()
+ instrument = controller.add_instrument(new_instrument("lead"))
+ controller.set_instrument_envelope(instrument.id, FeatureKey.VOLUME, Envelope(items=(15,)))
+
+ clone = controller.duplicate_voice(instrument.id)
+
+ assert clone.id != instrument.id
+ assert isinstance(clone, Instrument)
+ assert clone.envelopes == instrument.envelopes
class TestPatternManagement:
def test_add_pattern_returns_int_index(self) -> None:
controller = _controller()
- index = controller.add_pattern(GeneratorName.PULSE1)
+ index = controller.add_pattern(ChannelName.PULSE1)
assert isinstance(index, int)
def test_clone_pattern_creates_independent_copy(self) -> None:
controller = _controller()
- original_index = controller.add_pattern(GeneratorName.TRIANGLE)
+ original_index = controller.add_pattern(ChannelName.TRIANGLE)
clone_index = controller.clone_pattern(
- GeneratorName.TRIANGLE,
+ ChannelName.TRIANGLE,
original_index,
)
assert clone_index != original_index
- assert controller.song.pattern(GeneratorName.TRIANGLE, clone_index) is not None
+ assert controller.song.pattern(ChannelName.TRIANGLE, clone_index) is not None
class TestFrameManagement:
@@ -544,7 +598,7 @@ class TestExistingRow:
def test_update_row_on_nonexistent_pattern_is_no_op(self) -> None:
controller = _controller()
controller.update_row(
- GeneratorName.PULSE1,
+ ChannelName.PULSE1,
999,
0,
)
@@ -558,7 +612,7 @@ def test_sample_holds_reconstruction_by_reference(
reconstruction = reconstruction_factory()
sample = controller.add_sample(reconstruction, name="lead")
- assert controller.project.sample(sample.id).reconstruction is reconstruction
+ assert controller.project.voice(sample.id).reconstruction is reconstruction
def test_in_place_reconstruction_edit_is_visible_through_project(
self, reconstruction_factory: Callable[[], Reconstruction]
@@ -575,16 +629,16 @@ def test_in_place_reconstruction_edit_is_visible_through_project(
duty_cycle=1,
)
]
- reconstruction.update_generator_data(
- GeneratorName.PULSE1,
+ reconstruction.update_channel_data(
+ ChannelName.PULSE1,
new_instructions,
np.zeros(64, dtype=np.float32),
72,
(),
)
- stored = controller.project.sample(sample.id).reconstruction
- assert stored.get_generator_instructions(GeneratorName.PULSE1) == new_instructions
+ stored = controller.project.voice(sample.id).reconstruction
+ assert stored.get_channel_instructions(ChannelName.PULSE1) == new_instructions
class TestBatch:
@@ -592,12 +646,12 @@ def test_a_batch_announces_one_song_change_for_many_rows(self) -> None:
controller = _controller()
emitted: List[str] = []
controller.on_song_changed = lambda: emitted.append("song")
- pattern_index = controller.song.order[0][GeneratorName.PULSE1]
+ pattern_index = controller.song.order[0][ChannelName.PULSE1]
with controller.batch():
for row_index in range(8):
controller.set_row(
- GeneratorName.PULSE1,
+ ChannelName.PULSE1,
pattern_index,
row_index,
volume=15,
diff --git a/tests/unit/sampletones_application/logic/project/test_manager.py b/tests/unit/sampletones_application/logic/project/test_manager.py
index e0af6e437..66c243026 100644
--- a/tests/unit/sampletones_application/logic/project/test_manager.py
+++ b/tests/unit/sampletones_application/logic/project/test_manager.py
@@ -3,7 +3,7 @@
import pytest
from sampletones_application.logic.project.manager import ProjectManager
-from sampletones_core.constants.enums import GeneratorName
+from sampletones_core.constants.enums import ChannelName
from sampletones_shared.exceptions import NotAValidArchiveError
from tests.suite.errors import DIRECTORY_READ_ERRORS
@@ -11,8 +11,8 @@
class TestProjectManager:
def test_starts_with_a_clean_default_project(self) -> None:
manager = ProjectManager()
- assert set(manager.current.song.channels) == set(GeneratorName.items())
- assert len(manager.current.samples) == 0
+ assert set(manager.current.song.channels) == set(ChannelName.items())
+ assert len(manager.current.voices) == 0
assert manager.is_dirty is False
def test_mark_updated_sets_dirty(self) -> None:
@@ -25,7 +25,7 @@ def test_new_replaces_with_clean_project(self) -> None:
manager.mark_updated()
manager.new()
assert manager.is_dirty is False
- assert len(manager.current.samples) == 0
+ assert len(manager.current.voices) == 0
def test_save_load_round_trip(self, tmp_path: Path) -> None:
manager = ProjectManager()
@@ -42,7 +42,7 @@ def test_save_load_round_trip(self, tmp_path: Path) -> None:
assert loaded.name == "demo"
assert loaded.is_dirty is False
assert loaded.current.info.title == "Demo"
- assert set(loaded.current.song.channels) == set(GeneratorName.items())
+ assert set(loaded.current.song.channels) == set(ChannelName.items())
class TestLoadPropagatesErrors:
diff --git a/tests/unit/sampletones_application/logic/reconstruction/browser/conftest.py b/tests/unit/sampletones_application/logic/reconstruction/browser/conftest.py
index 8a1cae3e5..64e8fcfa7 100644
--- a/tests/unit/sampletones_application/logic/reconstruction/browser/conftest.py
+++ b/tests/unit/sampletones_application/logic/reconstruction/browser/conftest.py
@@ -37,7 +37,7 @@ def config_fields(
nes_frequency: int = 30,
spectrum_method: SpectrumMethod = SpectrumMethod.FFT,
transformation_gamma: int = 0,
- generators: str = "PTN",
+ channels: str = "PTN",
config_hash: str = HASH_A,
) -> ConfigDirectoryFields:
"""Builds configuration fields, so a test states only the field whose effect it examines."""
@@ -46,7 +46,7 @@ def config_fields(
nf=nes_frequency,
sm=spectrum_method,
tg=transformation_gamma,
- gn=generators,
+ gn=channels,
ch=config_hash,
)
diff --git a/tests/unit/sampletones_application/logic/reconstruction/browser/test_configurations.py b/tests/unit/sampletones_application/logic/reconstruction/browser/test_configurations.py
index 0da43ad99..e3fc2421d 100644
--- a/tests/unit/sampletones_application/logic/reconstruction/browser/test_configurations.py
+++ b/tests/unit/sampletones_application/logic/reconstruction/browser/test_configurations.py
@@ -63,7 +63,7 @@ def generator_directories(
class TestTopLevelConfigDirectories:
def test_config_directory_groups_by_frequencies_then_transformation(self) -> None:
- fields = config_fields(generators="PpT")
+ fields = config_fields(channels="PpT")
branch = build_branch(scan_of(config_entry(fields, "song")))
frequencies = group_children(branch)
@@ -118,8 +118,8 @@ def test_colliding_generators_get_a_hash_suffix(self) -> None:
}
def test_distinct_generators_share_a_transformation_group_under_their_own_names(self) -> None:
- first = config_fields(generators="PTN")
- second = config_fields(generators="TN")
+ first = config_fields(channels="PTN")
+ second = config_fields(channels="TN")
branch = build_branch(scan_of(config_entry(first, "song"), config_entry(second, "song")))
assert set(generator_directories(branch, first)) == {"PTN", "TN"}
diff --git a/tests/unit/sampletones_application/logic/reconstruction/test_audition.py b/tests/unit/sampletones_application/logic/reconstruction/test_audition.py
new file mode 100644
index 000000000..24b51d54d
--- /dev/null
+++ b/tests/unit/sampletones_application/logic/reconstruction/test_audition.py
@@ -0,0 +1,372 @@
+from typing import Final, List, Optional
+from unittest.mock import MagicMock
+
+import numpy as np
+import pytest
+
+from sampletones_application.config.managers.session import SessionManager
+from sampletones_application.constants.instruments import AUDITION_GENERATOR, AUDITION_TICKS
+from sampletones_application.logic.project.controller import ProjectController
+from sampletones_application.logic.project.manager import ProjectManager
+from sampletones_application.logic.reconstruction.audition import InstrumentAuditionLogic
+from sampletones_application.logic.shared.playback_priority import PlaybackPriority
+from sampletones_application.view_model.reconstruction.waveform import (
+ InstrumentWaveformViewModel,
+)
+from sampletones_core.audio import AudioDeviceManager
+from sampletones_core.configs import Config
+from sampletones_core.constants.enums import ChannelName, GeneratorName
+from sampletones_core.features.envelope import Envelope
+from sampletones_core.performance.audition import audition_audio, audition_ticks
+from sampletones_core.project.voices.envelopes import InstrumentEnvelopes
+from sampletones_core.project.voices.instrument import Instrument
+from sampletones_shared.exceptions import PlaybackError
+
+REFERENCE_PITCH: Final[int] = 60
+REFERENCE_PERIOD: Final[int] = 8
+OCTAVE: Final[int] = 2
+MIDDLE_C: Final[int] = 48
+SEMITONE_G: Final[int] = 7
+
+
+class _Editor:
+ """A tab holding one instrument, or none, which is all an audition reads of it."""
+
+ def __init__(self, instrument: Optional[Instrument]) -> None:
+ self._instrument = instrument
+
+ @property
+ def instrument(self) -> Optional[Instrument]:
+ return self._instrument
+
+
+def _instrument() -> Instrument:
+ return Instrument(
+ name="lead",
+ envelopes=InstrumentEnvelopes(
+ volume=Envelope[int](items=(15, 12)),
+ arpeggio=Envelope[int](items=(0, 4)),
+ duty_cycle=Envelope[int](items=(2,)),
+ ),
+ initial_pitch=REFERENCE_PITCH,
+ initial_period=REFERENCE_PERIOD,
+ )
+
+
+@pytest.fixture
+def controller() -> ProjectController:
+ return ProjectController(ProjectManager())
+
+
+@pytest.fixture
+def session() -> MagicMock:
+ manager = MagicMock(spec=SessionManager)
+ manager.octave = OCTAVE
+ return manager
+
+
+@pytest.fixture
+def device() -> MagicMock:
+ return MagicMock(spec=AudioDeviceManager)
+
+
+def _logic(
+ instrument: Optional[Instrument],
+ controller: ProjectController,
+ session: MagicMock,
+ device: MagicMock,
+) -> InstrumentAuditionLogic:
+ return InstrumentAuditionLogic(_Editor(instrument), controller, session, device)
+
+
+def _sounded(
+ instrument: Optional[Instrument],
+ controller: ProjectController,
+ session: MagicMock,
+ device: MagicMock,
+ generator_name: GeneratorName,
+ semitone: int,
+) -> None:
+ """Sounds one key of a voice on one generator, which is a choice and then a press."""
+ logic = _logic(instrument, controller, session, device)
+ logic.set_generator(generator_name)
+ logic.sound(semitone)
+
+
+def _played(device: MagicMock) -> np.ndarray:
+ audio = device.play.call_args.args[0]
+ assert isinstance(audio, np.ndarray)
+ return audio
+
+
+def _expected(
+ instrument: Instrument,
+ controller: ProjectController,
+ channel_name: ChannelName,
+ pitch: int,
+) -> np.ndarray:
+ settings = controller.project.settings
+ config = Config().with_library(
+ nes_frequency=settings.nes_frequency,
+ sample_rate=settings.sample_rate,
+ )
+ audio = audition_audio(
+ instrument,
+ channel_name,
+ config,
+ pitch=pitch,
+ ticks=audition_ticks(instrument, cap=AUDITION_TICKS),
+ )
+ assert audio is not None
+ return audio
+
+
+class TestWhatAKeySounds:
+ def test_a_key_sounds_the_voice_at_the_note_it_names(
+ self,
+ controller: ProjectController,
+ session: MagicMock,
+ device: MagicMock,
+ ) -> None:
+ instrument = _instrument()
+
+ _sounded(instrument, controller, session, device, GeneratorName.PULSE, SEMITONE_G)
+
+ expected = _expected(instrument, controller, ChannelName.PULSE1, MIDDLE_C + SEMITONE_G)
+ assert np.array_equal(_played(device), expected)
+
+ def test_the_octave_in_force_moves_the_note_the_same_key_sounds(
+ self,
+ controller: ProjectController,
+ session: MagicMock,
+ device: MagicMock,
+ ) -> None:
+ instrument = _instrument()
+ session.octave = OCTAVE + 1
+
+ _sounded(instrument, controller, session, device, GeneratorName.PULSE, SEMITONE_G)
+
+ expected = _expected(instrument, controller, ChannelName.PULSE1, MIDDLE_C + 12 + SEMITONE_G)
+ assert np.array_equal(_played(device), expected)
+
+ def test_the_generator_chosen_is_the_channel_the_voice_is_heard_on(
+ self,
+ controller: ProjectController,
+ session: MagicMock,
+ device: MagicMock,
+ ) -> None:
+ instrument = _instrument()
+
+ _sounded(instrument, controller, session, device, GeneratorName.TRIANGLE, SEMITONE_G)
+
+ expected = _expected(instrument, controller, ChannelName.TRIANGLE, MIDDLE_C + SEMITONE_G)
+ assert np.array_equal(_played(device), expected)
+
+ def test_the_noise_generator_sounds_at_the_period_the_voice_states(
+ self,
+ controller: ProjectController,
+ session: MagicMock,
+ device: MagicMock,
+ ) -> None:
+ """The noise channel selects one of sixteen periods, so a key there names no note."""
+ instrument = _instrument()
+ logic = _logic(instrument, controller, session, device)
+ logic.set_generator(GeneratorName.NOISE)
+
+ logic.sound(SEMITONE_G)
+ first = _played(device)
+ logic.sound(0)
+ second = _played(device)
+
+ expected = _expected(instrument, controller, ChannelName.NOISE, REFERENCE_PERIOD)
+ assert np.array_equal(first, expected)
+ assert np.array_equal(second, expected)
+
+ def test_an_audition_yields_to_the_playback_a_reader_asked_for(
+ self,
+ controller: ProjectController,
+ session: MagicMock,
+ device: MagicMock,
+ ) -> None:
+ _sounded(_instrument(), controller, session, device, GeneratorName.PULSE, SEMITONE_G)
+
+ assert device.play.call_args.kwargs["priority"] is PlaybackPriority.PREVIEW
+
+
+class TestTheCursorAnAuditionDraws:
+ """A voice being sounded carries a mark along the waveform the card draws."""
+
+ def test_an_audition_that_sounds_is_followed(
+ self,
+ controller: ProjectController,
+ session: MagicMock,
+ device: MagicMock,
+ ) -> None:
+ """The device reports where it has reached, which is what moves the mark."""
+ device.play.return_value = True
+ logic = _logic(_instrument(), controller, session, device)
+ marked: List[int] = []
+ logic.on_position_changed = marked.append
+
+ logic.sound(SEMITONE_G)
+ device.set_position_callback.call_args.args[0](512)
+
+ assert device.play.call_args.kwargs["update"] is True
+ assert marked == [512]
+
+ def test_an_audition_standing_aside_leaves_the_mark_where_it_is(
+ self,
+ controller: ProjectController,
+ session: MagicMock,
+ device: MagicMock,
+ ) -> None:
+ """A preview outranked by playback the reader asked for follows nothing."""
+ device.play.return_value = False
+ logic = _logic(_instrument(), controller, session, device)
+
+ logic.sound(SEMITONE_G)
+
+ device.set_position_callback.assert_not_called()
+
+
+class TestWhenNothingSounds:
+ def test_a_tab_holding_no_instrument_plays_nothing(
+ self,
+ controller: ProjectController,
+ session: MagicMock,
+ device: MagicMock,
+ ) -> None:
+ _sounded(None, controller, session, device, GeneratorName.PULSE, SEMITONE_G)
+
+ device.play.assert_not_called()
+
+ def test_a_voice_writing_no_envelope_plays_nothing(
+ self,
+ controller: ProjectController,
+ session: MagicMock,
+ device: MagicMock,
+ ) -> None:
+ _sounded(Instrument(name="silent"), controller, session, device, GeneratorName.PULSE, SEMITONE_G)
+
+ device.play.assert_not_called()
+
+ def test_a_device_refusing_the_audition_reports_what_it_refused_with(
+ self,
+ controller: ProjectController,
+ session: MagicMock,
+ device: MagicMock,
+ ) -> None:
+ refusal = PlaybackError("no device")
+ device.play.side_effect = refusal
+ logic = _logic(_instrument(), controller, session, device)
+ reported: List[Exception] = []
+ logic.on_audition_error = reported.append
+
+ logic.sound(SEMITONE_G)
+
+ assert reported == [refusal]
+
+
+class TestTheWaveformTheCardDraws:
+ def test_a_voice_is_first_heard_and_drawn_on_the_pulse(
+ self,
+ controller: ProjectController,
+ session: MagicMock,
+ device: MagicMock,
+ ) -> None:
+ assert AUDITION_GENERATOR is GeneratorName.PULSE
+
+ logic = _logic(_instrument(), controller, session, device)
+ drawn: List[Optional[InstrumentWaveformViewModel]] = []
+ logic.on_waveform_changed = drawn.append
+
+ logic.refresh()
+
+ assert drawn[-1] is not None
+ assert drawn[-1].channel_name is ChannelName.PULSE1
+
+ def test_the_open_voice_is_drawn_at_the_pitch_it_stands_at(
+ self,
+ controller: ProjectController,
+ session: MagicMock,
+ device: MagicMock,
+ ) -> None:
+ """The card shows the voice as it is, so no key just pressed moves what it draws."""
+ instrument = _instrument()
+ logic = _logic(instrument, controller, session, device)
+ drawn: List[Optional[InstrumentWaveformViewModel]] = []
+ logic.on_waveform_changed = drawn.append
+
+ logic.sound(SEMITONE_G)
+ logic.refresh()
+
+ expected = _expected(instrument, controller, ChannelName.PULSE1, REFERENCE_PITCH)
+ assert drawn[-1] is not None
+ assert np.array_equal(drawn[-1].audio, expected)
+
+ def test_choosing_a_generator_redraws_the_voice_as_that_one(
+ self,
+ controller: ProjectController,
+ session: MagicMock,
+ device: MagicMock,
+ ) -> None:
+ logic = _logic(_instrument(), controller, session, device)
+ drawn: List[Optional[InstrumentWaveformViewModel]] = []
+ logic.on_waveform_changed = drawn.append
+
+ logic.set_generator(GeneratorName.NOISE)
+
+ assert drawn[-1] is not None
+ assert drawn[-1].channel_name is ChannelName.NOISE
+
+ def test_the_voice_is_drawn_under_its_own_name_and_frame_length(
+ self,
+ controller: ProjectController,
+ session: MagicMock,
+ device: MagicMock,
+ ) -> None:
+ instrument = _instrument()
+ logic = _logic(instrument, controller, session, device)
+ drawn: List[Optional[InstrumentWaveformViewModel]] = []
+ logic.on_waveform_changed = drawn.append
+
+ logic.refresh()
+
+ settings = controller.project.settings
+ config = Config().with_library(
+ nes_frequency=settings.nes_frequency,
+ sample_rate=settings.sample_rate,
+ )
+ assert drawn[-1] is not None
+ assert drawn[-1].name == "lead"
+ assert drawn[-1].frame_length == config.frame_length
+ sounded = audition_ticks(instrument, cap=AUDITION_TICKS)
+ assert drawn[-1].audio.shape == (sounded * config.frame_length,)
+
+ def test_a_tab_holding_a_recording_leaves_the_card_to_it(
+ self,
+ controller: ProjectController,
+ session: MagicMock,
+ device: MagicMock,
+ ) -> None:
+ logic = _logic(None, controller, session, device)
+ drawn: List[Optional[InstrumentWaveformViewModel]] = []
+ logic.on_waveform_changed = drawn.append
+
+ logic.refresh()
+
+ assert drawn == [None]
+
+ def test_a_voice_writing_no_envelope_draws_nothing(
+ self,
+ controller: ProjectController,
+ session: MagicMock,
+ device: MagicMock,
+ ) -> None:
+ logic = _logic(Instrument(name="silent"), controller, session, device)
+ drawn: List[Optional[InstrumentWaveformViewModel]] = []
+ logic.on_waveform_changed = drawn.append
+
+ logic.refresh()
+
+ assert drawn == [None]
diff --git a/tests/unit/sampletones_application/logic/reconstruction/test_data.py b/tests/unit/sampletones_application/logic/reconstruction/test_data.py
index a8f734e8d..146b98806 100644
--- a/tests/unit/sampletones_application/logic/reconstruction/test_data.py
+++ b/tests/unit/sampletones_application/logic/reconstruction/test_data.py
@@ -4,10 +4,23 @@
import numpy as np
from sampletones_application.logic.reconstruction.data import ReconstructionData
-from sampletones_core.audio import write_wave
+from sampletones_core.audio import mix, write_wave
from sampletones_core.configs import Config
-from sampletones_core.constants.enums import GeneratorName
+from sampletones_core.constants.enums import ChannelName
+from sampletones_core.instructions import PulseInstruction
from sampletones_core.reconstructions import Reconstruction
+from sampletones_core.reconstructions.reconstruction.stems.channel_assignment import ChannelAssignment
+from sampletones_core.reconstructions.reconstruction.stems.data import StemsData
+from sampletones_core.reconstructions.reconstruction.stems.removal import without_stem
+from sampletones_core.reconstructions.reconstruction.stems.selection import StemSelection
+from sampletones_core.reconstructions.reconstructor.stems.configs.config import StemsConfig
+from sampletones_core.reconstructions.reconstructor.stems.configs.entry import StemEntry
+from sampletones_core.reconstructions.reconstructor.stems.configs.hierarchy import StemsHierarchy
+
+
+def _heard(*stem_ids: int) -> StemSelection:
+ """The selection hearing every named stem on every channel."""
+ return StemSelection.everywhere(frozenset(stem_ids), ChannelName.items())
class TestFromReconstruction:
@@ -58,7 +71,7 @@ def test_detached_reconstruction_has_no_original_audio(
name="Sample",
)
- assert reconstruction.audio_filepath is None
+ assert reconstruction.audio_filepath == ()
assert data.original_audio is None
def test_loads_original_audio_when_source_file_is_available(
@@ -72,7 +85,7 @@ def test_loads_original_audio_when_source_file_is_available(
Config().library.sample_rate,
np.ones(64, dtype=np.float32) * 0.5,
)
- reconstruction = reconstruction_factory().model_copy(update={"audio_filepath": source_audio})
+ reconstruction = reconstruction_factory().model_copy(update={"audio_filepath": (source_audio,)})
data = ReconstructionData.from_reconstruction(
reconstruction,
@@ -81,6 +94,45 @@ def test_loads_original_audio_when_source_file_is_available(
assert data.original_audio is not None
+ def test_mixes_several_recorded_paths_at_the_balance_they_were_captured_in(
+ self,
+ reconstruction_factory: Callable[[], Reconstruction],
+ tmp_path: Path,
+ ) -> None:
+ """The recordings are scaled together, so one heard alone sounds at its mix level.
+
+ The louder recording is written at twice the quieter one, and it stays twice as loud
+ once loaded, while their mix reaches the full range.
+ """
+ config = Config()
+ first = tmp_path / "kick.wav"
+ second = tmp_path / "snare.wav"
+ write_wave(first, config.library.sample_rate, np.ones(64, dtype=np.float32) * 0.5)
+ write_wave(second, config.library.sample_rate, np.ones(64, dtype=np.float32) * 0.25)
+ reconstruction = reconstruction_factory().model_copy(update={"audio_filepath": (first, second)})
+
+ data = ReconstructionData.from_reconstruction(reconstruction, name="Sample")
+
+ assert data.original_audio is not None
+ louder, quieter = data.stem_audios
+ np.testing.assert_allclose(louder, 2.0 * quieter, rtol=1e-6)
+ np.testing.assert_allclose(data.original_audio, mix([louder, quieter]))
+ np.testing.assert_allclose(np.max(np.abs(data.original_audio)), 1.0, rtol=1e-6)
+
+ def test_one_unreadable_stem_costs_the_whole_original(
+ self,
+ reconstruction_factory: Callable[[], Reconstruction],
+ tmp_path: Path,
+ ) -> None:
+ first = tmp_path / "kick.wav"
+ missing = tmp_path / "gone.wav"
+ write_wave(first, Config().library.sample_rate, np.ones(64, dtype=np.float32) * 0.5)
+ reconstruction = reconstruction_factory().model_copy(update={"audio_filepath": (first, missing)})
+
+ data = ReconstructionData.from_reconstruction(reconstruction, name="Sample")
+
+ assert data.original_audio is None
+
class TestReconstructionDataLoad:
def test_load_round_trips_reconstruction(
@@ -170,8 +222,36 @@ def test_names_after_the_source_audio_when_present(
copy = data.detached_copy(tmp_path / "lead.stn")
- assert reconstruction.audio_filepath is not None
- assert copy.name == reconstruction.audio_filepath.stem
+ assert reconstruction.audio_filepath
+ assert copy.name == reconstruction.audio_filepath[0].stem
+
+ def test_names_after_the_shared_directory_of_stems(
+ self,
+ reconstruction_factory: Callable[[], Reconstruction],
+ tmp_path: Path,
+ ) -> None:
+ drums = tmp_path / "drums"
+ drums.mkdir()
+ stems = (drums / "kick.wav", drums / "snare.wav")
+ reconstruction = reconstruction_factory().model_copy(update={"audio_filepath": stems})
+ data = ReconstructionData.from_reconstruction(reconstruction, name="Sample")
+
+ copy = data.detached_copy(tmp_path / "lead.stn")
+
+ assert copy.name == "drums"
+
+ def test_names_after_the_first_source_when_stems_share_no_directory(
+ self,
+ reconstruction_factory: Callable[[], Reconstruction],
+ tmp_path: Path,
+ ) -> None:
+ stems = (Path("/one/kick.wav"), Path("/two/snare.wav"))
+ reconstruction = reconstruction_factory().model_copy(update={"audio_filepath": stems})
+ data = ReconstructionData.from_reconstruction(reconstruction, name="Sample")
+
+ copy = data.detached_copy(tmp_path / "lead.stn")
+
+ assert copy.name == "kick"
def test_reuses_the_already_loaded_original_audio(
self,
@@ -184,7 +264,7 @@ def test_reuses_the_already_loaded_original_audio(
Config().library.sample_rate,
np.ones(64, dtype=np.float32) * 0.5,
)
- reconstruction = reconstruction_factory().model_copy(update={"audio_filepath": source_audio})
+ reconstruction = reconstruction_factory().model_copy(update={"audio_filepath": (source_audio,)})
data = ReconstructionData.from_reconstruction(
reconstruction,
name="Sample",
@@ -192,8 +272,203 @@ def test_reuses_the_already_loaded_original_audio(
copy = data.detached_copy(tmp_path / "lead.stn")
+ assert copy.stem_audios is data.stem_audios
assert data.original_audio is not None
- assert copy.original_audio is data.original_audio
+ np.testing.assert_allclose(copy.original_audio, data.original_audio)
+
+
+class TestStemFilteredProjections:
+ def _stems_data(
+ self,
+ reconstruction_factory: Callable[[], Reconstruction],
+ tmp_path: Path,
+ ) -> ReconstructionData:
+ config = Config()
+ frame_length = config.library.frame_length
+ length = 2 * frame_length
+ approximation = np.arange(length, dtype=np.float32)
+ stems_config = StemsConfig(
+ entries=[
+ StemEntry(id=0, channels=[ChannelName.PULSE1]),
+ StemEntry(id=1, channels=[ChannelName.PULSE1]),
+ ],
+ hierarchy=StemsHierarchy(levels=[[0, 1]]),
+ )
+ reconstruction = Reconstruction.create(
+ approximation=approximation,
+ approximations={ChannelName.PULSE1: approximation.copy()},
+ instructions={ChannelName.PULSE1: [PulseInstruction(on=True, pitch=60, volume=8, duty_cycle=0)] * 2},
+ config=config,
+ coefficient=1.0,
+ audio_filepath=(tmp_path / "a.wav", tmp_path / "b.wav"),
+ stems_data=StemsData(
+ config=stems_config,
+ assignments=[
+ ChannelAssignment(
+ channel_name=ChannelName.PULSE1,
+ stem_ids=[0, 1],
+ )
+ ],
+ ),
+ )
+ return ReconstructionData.from_reconstruction(reconstruction, name="Sample")
+
+ def test_partials_keep_only_the_selected_stems_frames(
+ self,
+ reconstruction_factory: Callable[[], Reconstruction],
+ tmp_path: Path,
+ ) -> None:
+ data = self._stems_data(reconstruction_factory, tmp_path)
+ frame_length = data.reconstruction.config.frame_length
+ expected = data.reconstruction.approximation.copy()
+ expected[frame_length:] = 0
+
+ partials = data.partials_for([ChannelName.PULSE1], _heard(0))
+
+ np.testing.assert_allclose(partials, expected)
+ np.testing.assert_allclose(
+ data.partials_for([ChannelName.PULSE1], _heard(0, 1)),
+ data.reconstruction.approximation,
+ )
+
+ def test_original_mix_mixes_the_selected_recordings(
+ self,
+ reconstruction_factory: Callable[[], Reconstruction],
+ tmp_path: Path,
+ ) -> None:
+ first = tmp_path / "kick.wav"
+ second = tmp_path / "snare.wav"
+ write_wave(first, Config().library.sample_rate, np.ones(64, dtype=np.float32) * 0.5)
+ write_wave(second, Config().library.sample_rate, np.ones(64, dtype=np.float32) * 0.25)
+ stems_config = StemsConfig(
+ entries=[
+ StemEntry(id=0, channels=[ChannelName.PULSE1]),
+ StemEntry(id=1, channels=[ChannelName.PULSE1]),
+ ],
+ hierarchy=StemsHierarchy(levels=[[0, 1]]),
+ )
+ reconstruction = reconstruction_factory().model_copy(
+ update={
+ "audio_filepath": (first, second),
+ "stems_data": StemsData(
+ config=stems_config,
+ assignments=[
+ ChannelAssignment(
+ channel_name=ChannelName.PULSE1,
+ stem_ids=[0, 1],
+ )
+ ],
+ ),
+ }
+ )
+
+ data = ReconstructionData.from_reconstruction(reconstruction, name="Sample")
+
+ np.testing.assert_allclose(data.original_mix_for(_heard(0)), data.stem_audios[0])
+ assert data.original_audio is not None
+ np.testing.assert_allclose(data.original_mix_for(_heard(0, 1)), data.original_audio)
+ np.testing.assert_array_equal(
+ data.original_mix_for(_heard()),
+ np.zeros_like(data.reconstruction.approximation),
+ )
+
+ def test_a_single_source_with_no_selection_is_silence(
+ self,
+ reconstruction_factory: Callable[[], Reconstruction],
+ tmp_path: Path,
+ ) -> None:
+ source_audio = tmp_path / "source.wav"
+ write_wave(source_audio, Config().library.sample_rate, np.ones(64, dtype=np.float32) * 0.5)
+ reconstruction = reconstruction_factory().model_copy(update={"audio_filepath": (source_audio,)})
+
+ data = ReconstructionData.from_reconstruction(reconstruction, name="Sample")
+
+ np.testing.assert_array_equal(
+ data.partials_for([ChannelName.PULSE1], _heard()),
+ np.zeros_like(data.reconstruction.approximation),
+ )
+ assert data.original_audio is not None
+ np.testing.assert_allclose(data.original_mix_for(_heard(0)), data.original_audio)
+
+
+class TestRebindingToAnEditedReconstruction:
+ def _three_recordings(
+ self,
+ reconstruction_factory: Callable[[], Reconstruction],
+ tmp_path: Path,
+ ) -> ReconstructionData:
+ """A document over three recordings, each carrying a shape of its own.
+
+ The shapes differ rather than the levels, since loading normalizes each recording and
+ would read three levels of one shape as the same waveform.
+ """
+ sample_rate = Config().library.sample_rate
+ shapes = (
+ np.linspace(-1.0, 1.0, 64, dtype=np.float32),
+ np.linspace(1.0, -1.0, 64, dtype=np.float32),
+ np.concatenate([np.ones(32, dtype=np.float32), -np.ones(32, dtype=np.float32)]),
+ )
+ paths = []
+ for index, shape in enumerate(shapes):
+ path = tmp_path / f"stem_{index}.wav"
+ write_wave(path, sample_rate, shape)
+ paths.append(path)
+
+ reconstruction = reconstruction_factory().model_copy(
+ update={
+ "audio_filepath": tuple(paths),
+ "stems_data": StemsData(
+ config=StemsConfig(
+ entries=[StemEntry(id=stem_id, channels=[ChannelName.PULSE1]) for stem_id in range(3)],
+ hierarchy=StemsHierarchy(levels=[[0], [1], [2]]),
+ ),
+ assignments=[
+ ChannelAssignment(
+ channel_name=ChannelName.PULSE1,
+ stem_ids=[0],
+ )
+ ],
+ ),
+ }
+ )
+ return ReconstructionData.from_reconstruction(reconstruction, name="Sample")
+
+ def test_a_recording_follows_the_entry_it_was_loaded_for(
+ self,
+ reconstruction_factory: Callable[[], Reconstruction],
+ tmp_path: Path,
+ ) -> None:
+ """A recording is read by position, so one entry leaving would slide the rest onto the wrong audio."""
+ data = self._three_recordings(reconstruction_factory, tmp_path)
+ third = data.stem_audios[2]
+
+ remaining = data.with_reconstruction(without_stem(data.reconstruction, 1))
+
+ np.testing.assert_allclose(remaining.original_mix_for(_heard(2)), third)
+
+ def test_the_removed_recording_is_released(
+ self,
+ reconstruction_factory: Callable[[], Reconstruction],
+ tmp_path: Path,
+ ) -> None:
+ data = self._three_recordings(reconstruction_factory, tmp_path)
+ second = data.stem_audios[1]
+
+ remaining = data.with_reconstruction(without_stem(data.reconstruction, 1))
+
+ assert len(remaining.stem_audios) == 2
+ assert all(not np.array_equal(audio, second) for audio in remaining.stem_audios)
+
+ def test_an_edit_keeping_every_entry_keeps_every_recording(
+ self,
+ reconstruction_factory: Callable[[], Reconstruction],
+ tmp_path: Path,
+ ) -> None:
+ data = self._three_recordings(reconstruction_factory, tmp_path)
+
+ rebound = data.with_reconstruction(data.reconstruction.model_copy())
+
+ assert rebound.stem_audios == data.stem_audios
class TestWaveformData:
@@ -241,7 +516,7 @@ def test_unknown_generator_returns_zeros(
name="Sample",
)
- result = data.get_partials([GeneratorName.TRIANGLE])
+ result = data.get_partials([ChannelName.TRIANGLE])
assert np.all(result == 0.0)
@@ -255,7 +530,7 @@ def test_known_generator_returns_its_approximation(
name="Sample",
)
- result = data.get_partials([GeneratorName.PULSE1])
+ result = data.get_partials([ChannelName.PULSE1])
- expected = reconstruction.approximations[GeneratorName.PULSE1]
+ expected = reconstruction.approximations[ChannelName.PULSE1]
assert np.array_equal(result, expected)
diff --git a/tests/unit/sampletones_application/logic/reconstruction/test_editor.py b/tests/unit/sampletones_application/logic/reconstruction/test_editor.py
new file mode 100644
index 000000000..c2fb0ccc8
--- /dev/null
+++ b/tests/unit/sampletones_application/logic/reconstruction/test_editor.py
@@ -0,0 +1,165 @@
+from typing import Final, Tuple
+from unittest.mock import MagicMock
+
+import pytest
+
+from sampletones_application.logic.history.manager import HistoryManager
+from sampletones_application.logic.project.controller import ProjectController
+from sampletones_application.logic.project.manager import ProjectManager
+from sampletones_application.logic.reconstruction.editing import InstrumentEdit, ReconstructionEdit
+from sampletones_application.logic.reconstruction.editor import InstrumentEditor
+from sampletones_application.logic.reconstruction.manager import ReconstructionManager
+from sampletones_core.constants.enums import ChannelName, FeatureKey
+from sampletones_core.exporters import Features
+from sampletones_core.features.envelope import Envelope
+from sampletones_core.project.voices.creation import SUSTAINING_ENVELOPES, new_instrument
+from sampletones_core.project.voices.instrument import Instrument
+
+ROOT_PITCH: Final[int] = 55
+VOLUME: Final[Tuple[int, ...]] = (15, 12, 9)
+
+
+def _features() -> Features:
+ return Features(
+ initial_pitch=ROOT_PITCH,
+ volume=Envelope(items=(15,)),
+ arpeggio=Envelope(items=(0,)),
+ pitch=None,
+ hi_pitch=None,
+ duty_cycle=None,
+ )
+
+
+@pytest.fixture
+def controller() -> ProjectController:
+ return ProjectController(ProjectManager())
+
+
+@pytest.fixture
+def reconstruction_manager() -> MagicMock:
+ manager = MagicMock(spec=ReconstructionManager)
+ manager.current_features = None
+ return manager
+
+
+HISTORY_BUDGET: Final[int] = 16
+
+
+@pytest.fixture
+def history(controller: ProjectController) -> HistoryManager:
+ """A strict history, so an edit landing outside a transaction is reported rather than healed."""
+ return HistoryManager(controller, budget=HISTORY_BUDGET, strict=True)
+
+
+@pytest.fixture
+def editor(
+ reconstruction_manager: MagicMock,
+ controller: ProjectController,
+ history: HistoryManager,
+) -> InstrumentEditor:
+ return InstrumentEditor(reconstruction_manager, controller, history, lambda _voice_id, _feature_key: ())
+
+
+class TestWhatTheTabHasInFront:
+ def test_it_holds_nothing_to_begin_with(self, editor: InstrumentEditor) -> None:
+ assert editor.edited_instrument() is None
+
+ def test_a_loaded_reconstruction_answers_with_its_channels(
+ self,
+ editor: InstrumentEditor,
+ reconstruction_manager: MagicMock,
+ ) -> None:
+ reconstruction_manager.current_features = MagicMock(channels={ChannelName.PULSE1: _features()})
+
+ edit = editor.edited_instrument()
+
+ assert isinstance(edit, ReconstructionEdit)
+ assert list(edit.channels) == [ChannelName.PULSE1]
+
+ def test_an_instrument_answers_with_what_it_states(
+ self,
+ editor: InstrumentEditor,
+ controller: ProjectController,
+ ) -> None:
+ """The pitch an export reads travels inside the envelopes the tab has in front of it."""
+ instrument = controller.add_instrument(
+ Instrument(name="lead", envelopes=SUSTAINING_ENVELOPES, initial_pitch=ROOT_PITCH)
+ )
+
+ editor.edit_instrument(instrument.id)
+
+ edit = editor.edited_instrument()
+ assert isinstance(edit, InstrumentEdit)
+ assert (edit.voice_id, edit.name) == (instrument.id, "lead")
+ assert edit.features.initial_pitch == ROOT_PITCH
+
+ def test_opening_an_instrument_closes_the_reconstruction_the_tab_held(
+ self,
+ editor: InstrumentEditor,
+ controller: ProjectController,
+ reconstruction_manager: MagicMock,
+ ) -> None:
+ """The tab describes one voice, so its waveform and stems follow what is in front of it."""
+ instrument = controller.add_instrument(new_instrument("lead"))
+
+ editor.edit_instrument(instrument.id)
+
+ reconstruction_manager.close_reconstruction.assert_called_once_with()
+
+ def test_letting_go_of_an_instrument_hands_the_tab_back(
+ self,
+ editor: InstrumentEditor,
+ controller: ProjectController,
+ reconstruction_manager: MagicMock,
+ ) -> None:
+ instrument = controller.add_instrument(new_instrument("lead"))
+ editor.edit_instrument(instrument.id)
+ reconstruction_manager.current_features = MagicMock(channels={ChannelName.PULSE1: _features()})
+
+ editor.release_instrument()
+
+ assert isinstance(editor.edited_instrument(), ReconstructionEdit)
+
+ def test_an_instrument_removed_from_the_project_leaves_the_tab_holding_nothing(
+ self,
+ editor: InstrumentEditor,
+ controller: ProjectController,
+ ) -> None:
+ instrument = controller.add_instrument(new_instrument("lead"))
+ editor.edit_instrument(instrument.id)
+
+ controller.remove_voice(instrument.id)
+
+ assert editor.edited_instrument() is None
+
+
+class TestWritingIntoTheInstrument:
+ def test_an_envelope_reaches_the_instrument(
+ self,
+ editor: InstrumentEditor,
+ controller: ProjectController,
+ ) -> None:
+ instrument = controller.add_instrument(new_instrument("lead"))
+ editor.edit_instrument(instrument.id)
+
+ editor.write_envelope(FeatureKey.VOLUME, Envelope(items=VOLUME))
+
+ assert instrument.envelopes.volume.items == VOLUME
+
+ def test_a_point_written_on_an_envelope_reaches_the_instrument(
+ self,
+ editor: InstrumentEditor,
+ controller: ProjectController,
+ ) -> None:
+ """A dimension carries the item it repeats from, so an edit writes both at once."""
+ instrument = controller.add_instrument(new_instrument("lead"))
+ editor.edit_instrument(instrument.id)
+
+ editor.write_envelope(FeatureKey.VOLUME, Envelope(items=(15, 8), loop_point=0))
+
+ assert instrument.envelopes.volume.items == (15, 8)
+ assert instrument.envelopes.volume.loop_point == 0
+
+ def test_a_write_with_no_instrument_in_front_is_refused(self, editor: InstrumentEditor) -> None:
+ with pytest.raises(RuntimeError):
+ editor.write_envelope(FeatureKey.VOLUME, Envelope(items=VOLUME))
diff --git a/tests/unit/sampletones_application/logic/reconstruction/test_feature.py b/tests/unit/sampletones_application/logic/reconstruction/test_feature.py
index 96bb0d6be..698659b9a 100644
--- a/tests/unit/sampletones_application/logic/reconstruction/test_feature.py
+++ b/tests/unit/sampletones_application/logic/reconstruction/test_feature.py
@@ -5,7 +5,7 @@
import pytest
from sampletones_application.logic.reconstruction.feature import FeatureData
-from sampletones_core.constants.enums import FeatureKey, GeneratorName
+from sampletones_core.constants.enums import ChannelName, FeatureKey
from sampletones_core.exporters import Features
from sampletones_core.reconstructions import Reconstruction
@@ -27,7 +27,7 @@ def test_load_creates_entry_for_each_generator(
self,
feature_data: FeatureData,
) -> None:
- assert set(feature_data.generators.keys()) == set(GeneratorName.items())
+ assert set(feature_data.channels.keys()) == set(ChannelName.items())
def test_a_channel_standing_by_carries_empty_envelopes(
self,
@@ -35,23 +35,23 @@ def test_a_channel_standing_by_carries_empty_envelopes(
feature_data: FeatureData,
) -> None:
"""A channel the reconstruction leaves silent is loaded describing no frame."""
- standing_by = set(GeneratorName.items()) - set(reconstruction.playing_generators)
+ standing_by = set(ChannelName.items()) - set(reconstruction.playing_channels)
assert standing_by
- assert all(not feature_data[generator_name].has_frames for generator_name in standing_by)
+ assert all(not feature_data[channel_name].has_frames for channel_name in standing_by)
def test_loaded_features_include_initial_pitch(
self,
feature_data: FeatureData,
) -> None:
- for features in feature_data.generators.values():
- assert features.get(FeatureKey.INITIAL_PITCH) is not None
+ for features in feature_data.channels.values():
+ assert features.initial_pitch is not None
class TestFeatureDataQueries:
- @pytest.mark.parametrize("generator_name", GeneratorName.items(), ids=lambda name: name.value)
+ @pytest.mark.parametrize("channel_name", ChannelName.items(), ids=lambda name: name.value)
def test_every_channel_answers_with_its_features(
self,
feature_data: FeatureData,
- generator_name: GeneratorName,
+ channel_name: ChannelName,
) -> None:
- assert isinstance(feature_data[generator_name], Features)
+ assert isinstance(feature_data[channel_name], Features)
diff --git a/tests/unit/sampletones_application/logic/reconstruction/test_instrument_history.py b/tests/unit/sampletones_application/logic/reconstruction/test_instrument_history.py
new file mode 100644
index 000000000..54565188d
--- /dev/null
+++ b/tests/unit/sampletones_application/logic/reconstruction/test_instrument_history.py
@@ -0,0 +1,128 @@
+from typing import Final, List
+from unittest.mock import MagicMock
+
+import pytest
+
+from sampletones_application.logic.history.action import HistoryAction
+from sampletones_application.logic.history.manager import HistoryManager
+from sampletones_application.logic.project.controller import ProjectController
+from sampletones_application.logic.project.manager import ProjectManager
+from sampletones_application.logic.reconstruction.editor import InstrumentEditor
+from sampletones_application.logic.reconstruction.manager import ReconstructionManager
+from sampletones_application.view_model.shared.history import (
+ HistoryDetail,
+ HistoryDetailRole,
+ HistoryDetailSegment,
+)
+from sampletones_core.constants.enums import FeatureKey
+from sampletones_core.features.envelope import Envelope
+from sampletones_core.project.voices.creation import new_instrument
+from sampletones_core.project.voices.instrument import Instrument
+
+HISTORY_BUDGET: Final[int] = 16
+VOLUME_LETTER: Final[str] = "V"
+
+
+class _Harness:
+ """The tab's write boundary over a strict history, which is how the application builds it."""
+
+ def __init__(self) -> None:
+ self.controller = ProjectController(ProjectManager())
+ self.history = HistoryManager(self.controller, budget=HISTORY_BUDGET, strict=True)
+ self.controller.on_mutation = self.history.handle_mutation
+ self.controller.new()
+
+ self.details: List[str] = []
+ reconstruction_manager = MagicMock(spec=ReconstructionManager)
+ reconstruction_manager.current_features = None
+ self.editor = InstrumentEditor(
+ reconstruction_manager,
+ self.controller,
+ self.history,
+ self._detail,
+ )
+
+ def _detail(self, voice_id: str, feature_key: FeatureKey) -> HistoryDetail:
+ self.details.append(f"{voice_id}:{feature_key.value}")
+ return (
+ HistoryDetailSegment(text=voice_id, role=HistoryDetailRole.INSTRUMENT),
+ HistoryDetailSegment(text=VOLUME_LETTER, role=HistoryDetailRole.FEATURE_VOLUME),
+ )
+
+ def open_instrument(self, name: str = "lead") -> Instrument:
+ """Adds a voice and puts it in front of the tab, as the pool and the tab each do."""
+ with self.history.transaction(HistoryAction.ADD_INSTRUMENT):
+ instrument = self.controller.add_instrument(new_instrument(name))
+
+ self.editor.edit_instrument(instrument.id)
+ return instrument
+
+ def write(self, feature_key: FeatureKey, *items: int) -> None:
+ self.editor.write_envelope(feature_key, Envelope[int](items=items))
+
+
+@pytest.fixture
+def harness() -> _Harness:
+ return _Harness()
+
+
+class TestAnInstrumentEditInTheHistory:
+ def test_an_edit_is_recorded_as_the_edit_it_is(self, harness: _Harness) -> None:
+ """A write outside a transaction would be reported by the strict history instead."""
+ harness.open_instrument()
+
+ harness.write(FeatureKey.VOLUME, 15, 12, 9)
+
+ assert [entry.action for entry in harness.history.entries[1:]] == [HistoryAction.EDIT_INSTRUMENT]
+
+ def test_the_entry_names_the_voice_and_the_dimension_it_moved(self, harness: _Harness) -> None:
+ instrument = harness.open_instrument()
+
+ harness.write(FeatureKey.VOLUME, 15)
+
+ assert harness.details == [f"{instrument.id}:{FeatureKey.VOLUME.value}"]
+ assert harness.history.entries[-1].detail[-1].text == VOLUME_LETTER
+
+ def test_a_drag_across_one_dimension_is_undone_in_one_step(self, harness: _Harness) -> None:
+ harness.open_instrument()
+
+ for level in (15, 14, 13, 12):
+ harness.write(FeatureKey.VOLUME, level)
+
+ assert [entry.action for entry in harness.history.entries[1:]] == [HistoryAction.EDIT_INSTRUMENT]
+
+ def test_undoing_that_drag_returns_the_envelope_it_started_from(self, harness: _Harness) -> None:
+ instrument = harness.open_instrument()
+ started_from = harness.controller.project.voices[instrument.id].envelopes.volume
+
+ for level in (15, 14, 13, 12):
+ harness.write(FeatureKey.VOLUME, level)
+
+ harness.history.undo()
+
+ assert harness.controller.project.voices[instrument.id].envelopes.volume == started_from
+
+ def test_two_dimensions_are_two_entries(self, harness: _Harness) -> None:
+ """Each dimension is its own target, so moving one leaves the other's entry standing."""
+ harness.open_instrument()
+
+ harness.write(FeatureKey.VOLUME, 15)
+ harness.write(FeatureKey.ARPEGGIO, 0, 4, 7)
+
+ assert [entry.action for entry in harness.history.entries[1:]] == [
+ HistoryAction.EDIT_INSTRUMENT,
+ HistoryAction.EDIT_INSTRUMENT,
+ ]
+
+ def test_two_voices_are_two_entries(self, harness: _Harness) -> None:
+ harness.open_instrument("lead")
+ harness.write(FeatureKey.VOLUME, 15)
+
+ harness.open_instrument("bass")
+ harness.write(FeatureKey.VOLUME, 15)
+
+ assert [entry.action for entry in harness.history.entries[1:]].count(HistoryAction.EDIT_INSTRUMENT) == 2
+
+ def test_writing_with_no_instrument_open_is_refused(self, harness: _Harness) -> None:
+ with pytest.raises(RuntimeError):
+ harness.write(FeatureKey.VOLUME, 15)
diff --git a/tests/unit/sampletones_application/logic/reconstruction/test_instruments.py b/tests/unit/sampletones_application/logic/reconstruction/test_instruments.py
index 3ab55ba90..ca8168fe7 100644
--- a/tests/unit/sampletones_application/logic/reconstruction/test_instruments.py
+++ b/tests/unit/sampletones_application/logic/reconstruction/test_instruments.py
@@ -1,10 +1,14 @@
-from typing import Callable, Dict, List, Optional
+from typing import Callable, Dict, Final, List, Optional
from unittest.mock import MagicMock
-import numpy as np
import pytest
+from sampletones_application.constants.instruments import INSTRUMENT_CHANNEL
from sampletones_application.layout.behavior.scheduling.scheduling import SchedulingBehavior
+from sampletones_application.logic.history.manager import HistoryManager
+from sampletones_application.logic.project.controller import ProjectController
+from sampletones_application.logic.project.manager import ProjectManager
+from sampletones_application.logic.reconstruction.editor import InstrumentEditor
from sampletones_application.logic.reconstruction.feature import FeatureData
from sampletones_application.logic.reconstruction.instruments import (
ReconstructionInstrumentsLogic,
@@ -13,27 +17,50 @@
from sampletones_application.view_model.reconstruction.instruments import (
ReconstructionInstrumentsViewModel,
)
-from sampletones_core.constants.enums import FeatureKey, GeneratorName
+from sampletones_core.constants.enums import ChannelName, FeatureKey
from sampletones_core.exporters import Features
+from sampletones_core.features.envelope import Envelope
from sampletones_core.formats.famitracker.footprint import (
features_footprint,
total_footprint,
)
+from sampletones_core.project.voices.creation import new_instrument
from sampletones_core.reconstructions import Reconstruction
+HISTORY_BUDGET: Final[int] = 16
+
+
+def _editor(
+ reconstruction_manager: MagicMock,
+ controller: ProjectController,
+) -> InstrumentEditor:
+ """The editor over a strict history, which is how the application builds it."""
+ return InstrumentEditor(
+ reconstruction_manager,
+ controller,
+ HistoryManager(controller, budget=HISTORY_BUDGET, strict=True),
+ lambda _voice_id, _feature_key: (),
+ )
+
@pytest.fixture
def mock_reconstruction_manager() -> MagicMock:
return MagicMock(spec=ReconstructionManager)
+@pytest.fixture
+def instrument_editor(mock_reconstruction_manager: MagicMock) -> InstrumentEditor:
+ """The real source the panel reads, over a stand-in for the document it opens."""
+ return _editor(mock_reconstruction_manager, ProjectController(ProjectManager()))
+
+
@pytest.fixture
def instruments_logic(
- mock_reconstruction_manager: MagicMock,
+ instrument_editor: InstrumentEditor,
scheduling: SchedulingBehavior,
) -> ReconstructionInstrumentsLogic:
return ReconstructionInstrumentsLogic(
- mock_reconstruction_manager,
+ instrument_editor,
scheduling=scheduling,
)
@@ -57,7 +84,7 @@ def test_no_features_fires_on_feature_data_changed_with_none(
mock_reconstruction_manager: MagicMock,
) -> None:
mock_reconstruction_manager.current_features = None
- received: List[Optional[Dict[GeneratorName, Features]]] = []
+ received: List[Optional[Dict[ChannelName, Features]]] = []
instruments_logic.on_feature_data_changed = received.append
instruments_logic.update_display()
assert received == [None]
@@ -82,10 +109,10 @@ def test_with_features_fires_on_feature_data_changed_with_data(
) -> None:
feature_data = FeatureData.load(reconstruction_factory())
mock_reconstruction_manager.current_features = feature_data
- received: List[Optional[Dict[GeneratorName, Features]]] = []
+ received: List[Optional[Dict[ChannelName, Features]]] = []
instruments_logic.on_feature_data_changed = received.append
instruments_logic.update_display()
- assert received == [feature_data.generators]
+ assert received == [feature_data.channels]
def test_with_features_exposes_the_playing_generators(
self,
@@ -97,7 +124,7 @@ def test_with_features_exposes_the_playing_generators(
received: List[ReconstructionInstrumentsViewModel] = []
instruments_logic.on_view_changed = received.append
instruments_logic.update_display()
- assert GeneratorName.PULSE1 in received[0].playing_generators
+ assert ChannelName.PULSE1 in received[0].playing_channels
class TestReconstructionInstrumentsLogicFootprint:
@@ -127,8 +154,8 @@ def test_every_playing_channel_is_measured(
instruments_logic.update_display()
footprint = received[0].footprint
assert footprint is not None
- assert {instrument.generator for instrument in footprint.instruments} == {
- generator_name for generator_name, features in feature_data.generators.items() if features.has_frames
+ assert {instrument.channel for instrument in footprint.instruments} == {
+ channel_name for channel_name, features in feature_data.channels.items() if features.has_frames
}
def test_the_size_is_the_one_a_one_shot_export_writes(
@@ -146,9 +173,7 @@ def test_the_size_is_the_one_a_one_shot_export_writes(
footprint = received[0].footprint
assert footprint is not None
expected = total_footprint(
- features_footprint(features, loop=False)
- for features in feature_data.generators.values()
- if features.has_frames
+ features_footprint(features) for features in feature_data.channels.values() if features.has_frames
)
assert footprint.total_bytes == expected.total_bytes
@@ -164,37 +189,17 @@ def test_an_envelope_edit_is_measured_as_it_arrives(
received: List[ReconstructionInstrumentsViewModel] = []
instruments_logic.on_view_changed = received.append
- volume = np.array([15, 12, 8, 4, 0], dtype=np.int8)
- instruments_logic.handle_raw_data_changed(
- GeneratorName.PULSE1,
+ volume = Envelope[int](items=(15, 12, 8, 4, 0))
+ instruments_logic.handle_envelope_changed(
+ ChannelName.PULSE1,
FeatureKey.VOLUME,
volume,
)
- edited = feature_data.generators[GeneratorName.PULSE1].model_copy(deep=True)
- edited[FeatureKey.VOLUME] = volume
+ edited = feature_data.channels[ChannelName.PULSE1].with_envelope(FeatureKey.VOLUME, volume)
footprint = received[0].footprint
assert footprint is not None
- assert footprint.bytes_for(GeneratorName.PULSE1) == features_footprint(edited, loop=False).total_bytes
-
- def test_a_bar_edit_is_measured_as_it_arrives(
- self,
- instruments_logic: ReconstructionInstrumentsLogic,
- mock_reconstruction_manager: MagicMock,
- reconstruction_factory: Callable[[], Reconstruction],
- ) -> None:
- feature_data = FeatureData.load(reconstruction_factory())
- mock_reconstruction_manager.current_features = feature_data
- received: List[ReconstructionInstrumentsViewModel] = []
- instruments_logic.on_view_changed = received.append
-
- instruments_logic.handle_bar_point_clicked(
- GeneratorName.PULSE1,
- FeatureKey.ARPEGGIO,
- np.zeros(6, dtype=np.int8),
- )
-
- assert received[0].footprint is not None
+ assert footprint.bytes_for(ChannelName.PULSE1) == features_footprint(edited).total_bytes
def test_measuring_an_edit_leaves_the_loaded_envelopes_as_they_are(
self,
@@ -202,18 +207,18 @@ def test_measuring_an_edit_leaves_the_loaded_envelopes_as_they_are(
mock_reconstruction_manager: MagicMock,
reconstruction_factory: Callable[[], Reconstruction],
) -> None:
- """The regeneration owns the loaded envelopes, so the measurement reads a copy."""
+ """The regeneration owns the loaded envelopes, so the measurement reads them without writing."""
feature_data = FeatureData.load(reconstruction_factory())
mock_reconstruction_manager.current_features = feature_data
- loaded_volume = feature_data.generators[GeneratorName.PULSE1].volume.copy()
+ loaded_volume = feature_data.channels[ChannelName.PULSE1].volume
- instruments_logic.handle_raw_data_changed(
- GeneratorName.PULSE1,
+ instruments_logic.handle_envelope_changed(
+ ChannelName.PULSE1,
FeatureKey.VOLUME,
- np.array([15, 12, 8, 4, 0], dtype=np.int8),
+ Envelope[int](items=(15, 12, 8, 4, 0)),
)
- assert np.array_equal(feature_data.generators[GeneratorName.PULSE1].volume, loaded_volume)
+ assert feature_data.channels[ChannelName.PULSE1].volume == loaded_volume
def test_a_refresh_reports_the_view_alone(
self,
@@ -224,7 +229,7 @@ def test_a_refresh_reports_the_view_alone(
"""A regenerated reconstruction refreshes the figures, leaving the edited envelopes displayed."""
mock_reconstruction_manager.current_features = FeatureData.load(reconstruction_factory())
received: List[ReconstructionInstrumentsViewModel] = []
- feature_updates: List[Optional[Dict[GeneratorName, Features]]] = []
+ feature_updates: List[Optional[Dict[ChannelName, Features]]] = []
instruments_logic.on_view_changed = received.append
instruments_logic.on_feature_data_changed = feature_updates.append
@@ -243,7 +248,7 @@ def test_schedules_reconstruction_update(
) -> None:
callback = MagicMock()
instruments_logic.on_reconstruction_instrument_updated = callback
- instruments_logic.handle_pitch_value_changed(GeneratorName.PULSE1, 61)
+ instruments_logic.handle_pitch_value_changed(ChannelName.PULSE1, 61)
callback.assert_called_once()
def test_forwards_generator_pitch_feature_and_value(
@@ -253,43 +258,51 @@ def test_forwards_generator_pitch_feature_and_value(
) -> None:
callback = MagicMock()
instruments_logic.on_reconstruction_instrument_updated = callback
- instruments_logic.handle_pitch_value_changed(GeneratorName.PULSE1, 61)
- generator_name, _features, feature_key, value = callback.call_args.args
- assert generator_name == GeneratorName.PULSE1
+ instruments_logic.handle_pitch_value_changed(ChannelName.PULSE1, 61)
+ channel_name, feature_key, _ = callback.call_args.args
+ channel_features = mock_reconstruction_manager.current_features.channels[ChannelName.PULSE1]
+
+ assert channel_name == ChannelName.PULSE1
assert feature_key == FeatureKey.INITIAL_PITCH
- assert value == 61
+ channel_features.model_copy.assert_called_once_with(update={"initial_pitch": 61})
-class TestReconstructionInstrumentsLogicHandleBarPoint:
- def test_handle_bar_point_clicked_schedules_update(
+class TestReconstructionInstrumentsLogicHandleEnvelope:
+ def test_an_edited_envelope_schedules_an_update(
self,
instruments_logic: ReconstructionInstrumentsLogic,
mock_reconstruction_manager: MagicMock,
) -> None:
callback = MagicMock()
instruments_logic.on_reconstruction_instrument_updated = callback
- instruments_logic.handle_bar_point_clicked(
- GeneratorName.PULSE1,
+ instruments_logic.handle_envelope_changed(
+ ChannelName.PULSE1,
FeatureKey.VOLUME,
- np.zeros(4, dtype=np.float32),
+ Envelope[int](items=(0, 0, 0, 0)),
)
callback.assert_called_once()
-
-class TestReconstructionInstrumentsLogicHandleRawData:
- def test_handle_raw_data_changed_schedules_update(
+ def test_an_edited_envelope_keeps_the_point_it_was_given(
self,
instruments_logic: ReconstructionInstrumentsLogic,
mock_reconstruction_manager: MagicMock,
+ reconstruction_factory: Callable[[], Reconstruction],
) -> None:
- callback = MagicMock()
- instruments_logic.on_reconstruction_instrument_updated = callback
- instruments_logic.handle_raw_data_changed(
- GeneratorName.PULSE1,
+ """The panel states values and loop point together, so the update carries both."""
+ mock_reconstruction_manager.current_features = FeatureData.load(reconstruction_factory())
+ received: List[Features] = []
+ instruments_logic.on_reconstruction_instrument_updated = lambda _channel, _key, features: received.append(
+ features
+ )
+ arpeggio = Envelope[int](items=(0, 4, 7), loop_point=1)
+
+ instruments_logic.handle_envelope_changed(
+ ChannelName.PULSE1,
FeatureKey.ARPEGGIO,
- np.zeros(4, dtype=np.float32),
+ arpeggio,
)
- callback.assert_called_once()
+
+ assert received[0].arpeggio == arpeggio
class TestReconstructionInstrumentsLogicOnUpdateScheduled:
@@ -302,3 +315,109 @@ def test_no_pending_update_is_a_no_op(
instruments_logic._pending_reconstruction_update = None
instruments_logic._on_reconstruction_update_scheduled()
callback.assert_not_called()
+
+
+class TestTheInstrumentsPanelShowsAnInstrument:
+ """An instrument stands on no audio, so the panel shows one instrument and writes edits at once."""
+
+ @pytest.fixture
+ def project_controller(self) -> ProjectController:
+ return ProjectController(ProjectManager())
+
+ @pytest.fixture
+ def instrument_logic(
+ self,
+ mock_reconstruction_manager: MagicMock,
+ project_controller: ProjectController,
+ scheduling: SchedulingBehavior,
+ ) -> ReconstructionInstrumentsLogic:
+ mock_reconstruction_manager.current_features = None
+ editor = _editor(mock_reconstruction_manager, project_controller)
+ instrument = project_controller.add_instrument(new_instrument("lead"))
+ project_controller.set_instrument_envelope(instrument.id, FeatureKey.VOLUME, Envelope(items=(15, 12)))
+ editor.edit_instrument(instrument.id)
+ return ReconstructionInstrumentsLogic(editor, scheduling=scheduling)
+
+ def test_the_view_names_the_instrument_it_shows(
+ self,
+ instrument_logic: ReconstructionInstrumentsLogic,
+ ) -> None:
+ received: List[ReconstructionInstrumentsViewModel] = []
+ instrument_logic.on_view_changed = received.append
+
+ instrument_logic.update_display()
+
+ assert received[-1].edits_an_instrument is True
+ assert received[-1].instrument is not None
+ assert received[-1].instrument.name == "lead"
+
+ def test_the_tab_it_is_shown_under_plays_it(
+ self,
+ instrument_logic: ReconstructionInstrumentsLogic,
+ ) -> None:
+ """The tab that plays is the tab an export is offered from, so the button is reachable."""
+ received: List[ReconstructionInstrumentsViewModel] = []
+ instrument_logic.on_view_changed = received.append
+
+ instrument_logic.update_display()
+
+ assert received[-1].playing_channels == frozenset({INSTRUMENT_CHANNEL})
+
+ def test_an_instrument_with_nothing_written_stands_by(
+ self,
+ instrument_logic: ReconstructionInstrumentsLogic,
+ project_controller: ProjectController,
+ ) -> None:
+ """An export writes what has frames, so a voice holding none is offered no export."""
+ instrument = project_controller.project.voices[0]
+ project_controller.set_instrument_envelope(instrument.id, FeatureKey.VOLUME, Envelope(items=()))
+ received: List[ReconstructionInstrumentsViewModel] = []
+ instrument_logic.on_view_changed = received.append
+
+ instrument_logic.update_display()
+
+ assert received[-1].playing_channels == frozenset()
+
+ def test_the_envelopes_are_drawn_under_the_channel_that_reads_them_all(
+ self,
+ instrument_logic: ReconstructionInstrumentsLogic,
+ ) -> None:
+ received: List[Optional[Dict[ChannelName, Features]]] = []
+ instrument_logic.on_feature_data_changed = received.append
+
+ instrument_logic.update_display()
+
+ assert received[-1] is not None
+ assert list(received[-1]) == [INSTRUMENT_CHANNEL]
+
+ def test_an_envelope_edit_reaches_the_instrument_without_a_regeneration(
+ self,
+ instrument_logic: ReconstructionInstrumentsLogic,
+ project_controller: ProjectController,
+ ) -> None:
+ regenerated: List[object] = []
+ instrument_logic.on_reconstruction_instrument_updated = lambda *args: regenerated.append(args)
+
+ instrument_logic.handle_envelope_changed(
+ INSTRUMENT_CHANNEL,
+ FeatureKey.ARPEGGIO,
+ Envelope[int](items=(0, 7)),
+ )
+
+ instrument = project_controller.project.voices[project_controller.project.voices[0].id]
+ assert instrument.envelopes.arpeggio.items == (0, 7)
+ assert regenerated == []
+
+ def test_the_figure_measures_the_one_instrument_it_exports(
+ self,
+ instrument_logic: ReconstructionInstrumentsLogic,
+ project_controller: ProjectController,
+ ) -> None:
+ received: List[ReconstructionInstrumentsViewModel] = []
+ instrument_logic.on_view_changed = received.append
+
+ instrument_logic.update_display()
+
+ instrument = project_controller.project.voices[0]
+ assert received[-1].footprint is not None
+ assert received[-1].footprint.total_bytes == features_footprint(instrument.instrument_features()).total_bytes
diff --git a/tests/unit/sampletones_application/logic/reconstruction/test_manager.py b/tests/unit/sampletones_application/logic/reconstruction/test_manager.py
index cb0a74df3..a9b168cbb 100644
--- a/tests/unit/sampletones_application/logic/reconstruction/test_manager.py
+++ b/tests/unit/sampletones_application/logic/reconstruction/test_manager.py
@@ -1,17 +1,37 @@
from pathlib import Path
from typing import Callable
-from unittest.mock import MagicMock
+from unittest.mock import MagicMock, patch
import numpy as np
import pytest
from sampletones_application.logic.reconstruction.manager import ReconstructionManager
+from sampletones_core.audio import write_wave
from sampletones_core.configs import Config
-from sampletones_core.constants.enums import GeneratorName
+from sampletones_core.constants.enums import ChannelName, HierarchyMode
from sampletones_core.instructions import PulseInstruction
from sampletones_core.reconstructions import Reconstruction
+from sampletones_core.reconstructions.reconstruction.stems.data import StemsData
+from sampletones_core.reconstructions.reconstructor.stems.configs.config import StemsConfig
+from sampletones_core.reconstructions.reconstructor.stems.configs.entry import StemEntry
+from sampletones_core.reconstructions.reconstructor.stems.configs.hierarchy import StemsHierarchy
from sampletones_shared.exceptions import LoadReconstructionError
from tests.suite.errors import DIRECTORY_READ_ERRORS
+from tests.suite.stems import single_entry_stems_data
+
+
+def _two_entry_stems_data() -> StemsData:
+ return StemsData(
+ config=StemsConfig(
+ entries=[
+ StemEntry(id=0, channels=[ChannelName.PULSE1]),
+ StemEntry(id=1, channels=[ChannelName.PULSE1]),
+ ],
+ hierarchy=StemsHierarchy(levels=[[0, 1]], mode=HierarchyMode.STRICT),
+ channel_cap=1,
+ ),
+ assignments=[],
+ )
class TestLoadReconstructionPropagatesErrors:
@@ -283,14 +303,14 @@ def test_close_marks_session_closed(
reconstruction_manager.close_reconstruction()
assert not reconstruction_manager.session.is_loaded
- def test_close_resets_audio_filepath_to_none(
+ def test_close_resets_source_paths_to_empty(
self,
reconstruction_manager: ReconstructionManager,
reconstruction_factory: Callable[[], Reconstruction],
) -> None:
reconstruction_manager.load_reconstruction_object(reconstruction_factory(), name="Sample")
reconstruction_manager.close_reconstruction()
- assert reconstruction_manager.audio_filepath is None
+ assert reconstruction_manager.source_paths == ()
class TestReconstructionManagerProperties:
@@ -311,14 +331,14 @@ def test_filepath_property_is_none_for_in_memory_reconstruction(
reconstruction_manager.load_reconstruction_object(reconstruction_factory(), name="Sample")
assert reconstruction_manager.filepath is None
- def test_audio_filepath_returns_reconstruction_audio_filepath(
+ def test_source_paths_return_reconstruction_source_paths(
self,
reconstruction_manager: ReconstructionManager,
reconstruction_factory: Callable[[], Reconstruction],
) -> None:
reconstruction = reconstruction_factory()
reconstruction_manager.load_reconstruction_object(reconstruction, name="Sample")
- assert reconstruction_manager.audio_filepath == reconstruction.audio_filepath
+ assert reconstruction_manager.source_paths == reconstruction.audio_filepath
def test_current_features_is_populated_after_load(
self,
@@ -342,11 +362,11 @@ def test_filepath_is_none_when_nothing_loaded(
) -> None:
assert reconstruction_manager.filepath is None
- def test_audio_filepath_is_none_when_nothing_loaded(
+ def test_source_paths_are_empty_when_nothing_loaded(
self,
reconstruction_manager: ReconstructionManager,
) -> None:
- assert reconstruction_manager.audio_filepath is None
+ assert reconstruction_manager.source_paths == ()
class TestReconstructionManagerMarkUpdated:
@@ -378,11 +398,15 @@ def test_locate_audio_raises_file_not_found_when_audio_missing(
missing_path = tmp_path / "ghost.wav"
reconstruction = Reconstruction.create(
approximation=np.zeros(64, dtype=np.float32),
- approximations={GeneratorName.PULSE1: np.zeros(64, dtype=np.float32)},
- instructions={GeneratorName.PULSE1: [PulseInstruction(on=True, pitch=60, volume=8, duty_cycle=0)]},
+ approximations={ChannelName.PULSE1: np.zeros(64, dtype=np.float32)},
+ instructions={ChannelName.PULSE1: [PulseInstruction(on=True, pitch=60, volume=8, duty_cycle=0)]},
config=Config(),
coefficient=1.0,
- audio_filepath=missing_path,
+ audio_filepath=(missing_path,),
+ stems_data=single_entry_stems_data(
+ list(Config().generation.channels),
+ {ChannelName.PULSE1: [PulseInstruction(on=True, pitch=60, volume=8, duty_cycle=0)]},
+ ),
)
reconstruction_manager.load_reconstruction_object(reconstruction, name="Sample")
with pytest.raises(FileNotFoundError):
@@ -393,3 +417,52 @@ def test_locate_audio_returns_silently_when_nothing_loaded(
reconstruction_manager: ReconstructionManager,
) -> None:
reconstruction_manager.locate_original_audio()
+
+ def test_locate_audio_opens_the_recorded_paths(
+ self,
+ reconstruction_manager: ReconstructionManager,
+ tmp_path: Path,
+ ) -> None:
+ first = tmp_path / "kick.wav"
+ second = tmp_path / "snare.wav"
+ write_wave(first, Config().library.sample_rate, np.ones(64, dtype=np.float32))
+ write_wave(second, Config().library.sample_rate, np.ones(64, dtype=np.float32))
+ reconstruction = Reconstruction.create(
+ approximation=np.zeros(64, dtype=np.float32),
+ approximations={ChannelName.PULSE1: np.zeros(64, dtype=np.float32)},
+ instructions={ChannelName.PULSE1: [PulseInstruction(on=True, pitch=60, volume=8, duty_cycle=0)]},
+ config=Config(),
+ coefficient=1.0,
+ audio_filepath=(first, second),
+ stems_data=_two_entry_stems_data(),
+ )
+ reconstruction_manager.load_reconstruction_object(reconstruction, name="Sample")
+
+ with patch("sampletones_application.logic.reconstruction.manager.open_paths_in_explorer") as open_paths:
+ reconstruction_manager.locate_original_audio()
+
+ open_paths.assert_called_once_with((first, second))
+
+ def test_locate_audio_reports_the_first_missing_path(
+ self,
+ reconstruction_manager: ReconstructionManager,
+ tmp_path: Path,
+ ) -> None:
+ present = tmp_path / "kick.wav"
+ missing = tmp_path / "gone.wav"
+ write_wave(present, Config().library.sample_rate, np.ones(64, dtype=np.float32))
+ reconstruction = Reconstruction.create(
+ approximation=np.zeros(64, dtype=np.float32),
+ approximations={ChannelName.PULSE1: np.zeros(64, dtype=np.float32)},
+ instructions={ChannelName.PULSE1: [PulseInstruction(on=True, pitch=60, volume=8, duty_cycle=0)]},
+ config=Config(),
+ coefficient=1.0,
+ audio_filepath=(present, missing),
+ stems_data=_two_entry_stems_data(),
+ )
+ reconstruction_manager.load_reconstruction_object(reconstruction, name="Sample")
+
+ with pytest.raises(FileNotFoundError) as raised:
+ reconstruction_manager.locate_original_audio()
+
+ assert raised.value.filename == str(missing)
diff --git a/tests/unit/sampletones_application/logic/reconstruction/test_reconstruction.py b/tests/unit/sampletones_application/logic/reconstruction/test_reconstruction.py
index 0d426cfbc..a5e1a0065 100644
--- a/tests/unit/sampletones_application/logic/reconstruction/test_reconstruction.py
+++ b/tests/unit/sampletones_application/logic/reconstruction/test_reconstruction.py
@@ -6,43 +6,55 @@
import numpy as np
import pytest
+from sampletones_application.exports import build_export_backends
from sampletones_application.logic.reconstruction.data import ReconstructionData
from sampletones_application.logic.reconstruction.manager import ReconstructionManager
from sampletones_application.logic.reconstruction.reconstruction import (
ReconstructionPanelLogic,
)
-from sampletones_application.view_model.reconstruction.reconstruction import (
+from sampletones_application.view_model.reconstruction.paths.state import (
ReconstructionPathState,
+)
+from sampletones_application.view_model.reconstruction.reconstruction import (
ReconstructionViewModel,
)
from sampletones_core.audio import write_wave
from sampletones_core.configs import Config
-from sampletones_core.constants.enums import AudioSourceType, GeneratorName
+from sampletones_core.constants.enums import AudioSourceType, ChannelName
+from sampletones_core.exports.format import ExportFormat
from sampletones_core.instructions import TriangleInstruction
from sampletones_core.reconstructions import Reconstruction
-from sampletones_core.trackers.format import TrackerFormat
-from sampletones_core.trackers.registry import build_tracker_backends
+from sampletones_core.reconstructions.reconstruction.stems.channel_assignment import ChannelAssignment
+from sampletones_core.reconstructions.reconstruction.stems.data import StemsData
+from sampletones_core.reconstructions.reconstructor.stems.configs.config import StemsConfig
+from sampletones_core.reconstructions.reconstructor.stems.configs.entry import StemEntry
+from sampletones_core.reconstructions.reconstructor.stems.configs.hierarchy import StemsHierarchy
+from sampletones_shared.constants.nes import PAL_FREQUENCY
+from sampletones_shared.music import Tuning
from sampletones_shared.paths.extensions import (
EXT_FILE_BITPHASE,
EXT_FILE_INSTRUMENT,
EXT_FILE_JSON,
EXT_FILE_MODULE,
+ EXT_FILE_NSF,
)
from tests.suite.case import BaseRegularTestCase
NO_EXTENSION: Final[str] = ""
+RETUNED_A4_FREQUENCY: Final[float] = 432.0
@dataclass(frozen=True)
class FormatCase:
extension: str
- tracker_format: TrackerFormat
+ export_format: ExportFormat
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),
+ FormatCase(extension=EXT_FILE_INSTRUMENT, export_format=ExportFormat.FAMITRACKER),
+ FormatCase(extension=EXT_FILE_BITPHASE, export_format=ExportFormat.BITPHASE),
+ FormatCase(extension=EXT_FILE_JSON, export_format=ExportFormat.BITPHASE_PRESET),
+ FormatCase(extension=EXT_FILE_NSF, export_format=ExportFormat.NSF),
]
UNSUPPORTED_EXTENSIONS: Final[List[str]] = [".xm", EXT_FILE_MODULE, NO_EXTENSION]
@@ -74,29 +86,29 @@ def panel_logic(
session_manager: MagicMock,
mock_reconstruction_manager: MagicMock,
mock_export_service: MagicMock,
- mock_tracker_backends: Dict[TrackerFormat, MagicMock],
+ mock_export_backends: Dict[ExportFormat, MagicMock],
) -> ReconstructionPanelLogic:
return ReconstructionPanelLogic(
session_manager,
mock_reconstruction_manager,
mock_export_service,
- mock_tracker_backends,
+ mock_export_backends,
)
@pytest.fixture
-def mock_tracker_backends() -> Dict[TrackerFormat, MagicMock]:
+def mock_export_backends() -> Dict[ExportFormat, 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():
+ backends: Dict[ExportFormat, MagicMock] = {}
+ for export_format, backend in build_export_backends().items():
stub = MagicMock()
stub.supported_scopes = backend.supported_scopes
stub.extension.side_effect = backend.extension
- backends[tracker_format] = stub
+ backends[export_format] = stub
return backends
@@ -111,6 +123,31 @@ def loaded_data(
)
+@pytest.fixture
+def retuned_data(
+ reconstruction_factory: Callable[[], Reconstruction],
+) -> ReconstructionData:
+ """A reconstruction built against a concert pitch other than the standard one."""
+ reconstruction = reconstruction_factory()
+ library = reconstruction.config.library.model_copy(update={"a4_frequency": RETUNED_A4_FREQUENCY})
+ config = reconstruction.config.model_copy(update={"library": library})
+ return ReconstructionData.from_reconstruction(
+ reconstruction.model_copy(update={"config": config}),
+ name="Sample",
+ )
+
+
+@pytest.fixture
+def reclocked_data(
+ reconstruction_factory: Callable[[], Reconstruction],
+) -> ReconstructionData:
+ """A reconstruction running at the PAL rate, which a fresh configuration departs from."""
+ return ReconstructionData.from_reconstruction(
+ reconstruction_factory().with_nes_frequency(PAL_FREQUENCY),
+ name="Sample",
+ )
+
+
@pytest.fixture
def data_with_original_audio(
reconstruction_factory: Callable[[], Reconstruction],
@@ -122,7 +159,7 @@ def data_with_original_audio(
Config().library.sample_rate,
np.ones(64, dtype=np.float32) * 0.5,
)
- reconstruction = reconstruction_factory().model_copy(update={"audio_filepath": source_audio})
+ reconstruction = reconstruction_factory().model_copy(update={"audio_filepath": (source_audio,)})
return ReconstructionData.from_reconstruction(reconstruction, name="Sample")
@@ -224,16 +261,32 @@ class AudioPathCase(BaseRegularTestCase):
@pytest.mark.parametrize("case", test_cases, ids=lambda case: case.label)
def test_audio_path_state_follows_loaded_content(self, case: AudioPathCase) -> None:
- audio_filepath = Path("/songs/source.wav") if case.has_filepath else None
+ source_paths = (Path("/songs/source.wav"),) if case.has_filepath else ()
original_audio = np.zeros(4, dtype=np.float32) if case.has_content else None
view_model = ReconstructionPanelLogic._build_audio_path_view_model(
- audio_filepath,
+ source_paths,
original_audio,
)
assert view_model.state is case.expected
+ def test_stem_paths_report_multiple_state(self) -> None:
+ stem_paths = (
+ Path("/stems/drums/kick.wav"),
+ Path("/stems/drums/snare.wav"),
+ )
+ original_audio = np.zeros(4, dtype=np.float32)
+
+ view_model = ReconstructionPanelLogic._build_audio_path_view_model(
+ stem_paths,
+ original_audio,
+ )
+
+ assert view_model.state is ReconstructionPathState.MULTIPLE
+ assert view_model.paths == tuple(str(path) for path in stem_paths)
+ assert view_model.path == ""
+
class TestReconstructionPanelLogicUpdate:
def test_update_with_no_data_is_no_op(
@@ -303,8 +356,8 @@ def test_display_offers_the_channels_that_play(
panel_logic.display_reconstruction()
- assert received[0].playing_generators == frozenset({GeneratorName.PULSE1})
- assert received[0].selected_generators == frozenset({GeneratorName.PULSE1})
+ assert received[0].playing_channels == frozenset({ChannelName.PULSE1})
+ assert received[0].selected_channels == frozenset({ChannelName.PULSE1})
def test_an_edit_reports_the_view_again(
self,
@@ -318,7 +371,7 @@ def test_an_edit_reports_the_view_again(
panel_logic.update_reconstruction()
- assert received[0].playing_generators == frozenset({GeneratorName.PULSE1})
+ assert received[0].playing_channels == frozenset({ChannelName.PULSE1})
def test_a_channel_switched_off_by_hand_survives_an_edit(
self,
@@ -328,12 +381,12 @@ def test_a_channel_switched_off_by_hand_survives_an_edit(
) -> None:
mock_reconstruction_manager.current_reconstruction = loaded_data
panel_logic.display_reconstruction()
- panel_logic.set_selected_generators([])
+ panel_logic.set_selected_channels([])
received = self._received(panel_logic)
panel_logic.update_reconstruction()
- assert received[0].selected_generators == frozenset()
+ assert received[0].selected_channels == frozenset()
def test_a_channel_gaining_its_first_frame_joins_the_waveform(
self,
@@ -343,8 +396,8 @@ def test_a_channel_gaining_its_first_frame_joins_the_waveform(
) -> None:
mock_reconstruction_manager.current_reconstruction = loaded_data
panel_logic.display_reconstruction()
- loaded_data.reconstruction.update_generator_data(
- GeneratorName.TRIANGLE,
+ loaded_data.reconstruction.update_channel_data(
+ ChannelName.TRIANGLE,
[TriangleInstruction(on=True, pitch=48)],
np.ones(64, dtype=np.float32),
48,
@@ -354,8 +407,8 @@ def test_a_channel_gaining_its_first_frame_joins_the_waveform(
panel_logic.update_reconstruction()
- assert received[0].playing_generators == frozenset({GeneratorName.PULSE1, GeneratorName.TRIANGLE})
- assert received[0].selected_generators == frozenset({GeneratorName.PULSE1, GeneratorName.TRIANGLE})
+ assert received[0].playing_channels == frozenset({ChannelName.PULSE1, ChannelName.TRIANGLE})
+ assert received[0].selected_channels == frozenset({ChannelName.PULSE1, ChannelName.TRIANGLE})
def test_a_channel_taken_out_of_play_leaves_the_waveform(
self,
@@ -365,8 +418,8 @@ def test_a_channel_taken_out_of_play_leaves_the_waveform(
) -> None:
mock_reconstruction_manager.current_reconstruction = loaded_data
panel_logic.display_reconstruction()
- loaded_data.reconstruction.update_generator_data(
- GeneratorName.PULSE1,
+ loaded_data.reconstruction.update_channel_data(
+ ChannelName.PULSE1,
[],
np.zeros(0, dtype=np.float32),
60,
@@ -376,8 +429,41 @@ def test_a_channel_taken_out_of_play_leaves_the_waveform(
panel_logic.update_reconstruction()
- assert received[0].playing_generators == frozenset()
- assert received[0].selected_generators == frozenset()
+ assert received[0].playing_channels == frozenset()
+ assert received[0].selected_channels == frozenset()
+
+
+class TestReconstructionPanelLogicEngineRate:
+ """The rate the card states, which follows the open document."""
+
+ @staticmethod
+ def _received(panel_logic: ReconstructionPanelLogic) -> List[ReconstructionViewModel]:
+ received: List[ReconstructionViewModel] = []
+ panel_logic.on_view_changed = received.append
+ return received
+
+ def test_the_view_states_the_rate_the_reconstruction_runs_at(
+ self,
+ panel_logic: ReconstructionPanelLogic,
+ mock_reconstruction_manager: MagicMock,
+ reclocked_data: ReconstructionData,
+ ) -> None:
+ mock_reconstruction_manager.current_reconstruction = reclocked_data
+ received = self._received(panel_logic)
+
+ panel_logic.display_reconstruction()
+
+ assert received[0].nes_frequency == reclocked_data.config.nes_frequency
+
+ def test_a_closed_tab_states_no_rate(
+ self,
+ panel_logic: ReconstructionPanelLogic,
+ ) -> None:
+ received = self._received(panel_logic)
+
+ panel_logic.close_reconstruction()
+
+ assert received[0].nes_frequency is None
class TestReconstructionPanelLogicClose:
@@ -479,7 +565,7 @@ def test_set_audio_source_emits_audio_data(
callback.assert_called_once()
-class TestReconstructionPanelLogicSelectedGenerators:
+class TestReconstructionPanelLogicSelectedChannels:
def test_set_selected_generators_updates_selection(
self,
panel_logic: ReconstructionPanelLogic,
@@ -487,8 +573,8 @@ def test_set_selected_generators_updates_selection(
loaded_data: ReconstructionData,
) -> None:
mock_reconstruction_manager.current_reconstruction = loaded_data
- panel_logic.set_selected_generators([GeneratorName.PULSE1])
- assert panel_logic._selected_generators == [GeneratorName.PULSE1]
+ panel_logic.set_selected_channels([ChannelName.PULSE1])
+ assert panel_logic._selected_channels == [ChannelName.PULSE1]
def test_set_selected_generators_fires_waveform_load(
self,
@@ -499,7 +585,7 @@ def test_set_selected_generators_fires_waveform_load(
mock_reconstruction_manager.current_reconstruction = loaded_data
callback = MagicMock()
panel_logic.on_waveform_load_changed = callback
- panel_logic.set_selected_generators([GeneratorName.PULSE1])
+ panel_logic.set_selected_channels([ChannelName.PULSE1])
callback.assert_called_once()
def test_set_selected_generators_with_no_data_skips_waveform(
@@ -508,150 +594,102 @@ def test_set_selected_generators_with_no_data_skips_waveform(
) -> None:
callback = MagicMock()
panel_logic.on_waveform_load_changed = callback
- panel_logic.set_selected_generators([GeneratorName.PULSE1])
+ panel_logic.set_selected_channels([ChannelName.PULSE1])
callback.assert_not_called()
class TestReconstructionPanelLogicExportInstrument:
- def test_request_export_instrument_dialog_with_no_data_raises_assertion_error(
+ """What the tab offers to an export: the slice one channel holds, ready for a destination."""
+
+ def test_with_no_data_raises_assertion_error(
self,
panel_logic: ReconstructionPanelLogic,
) -> None:
with pytest.raises(AssertionError):
- panel_logic.request_export_instrument_dialog(GeneratorName.PULSE1)
+ panel_logic.exportable_instrument(ChannelName.PULSE1)
- def test_request_export_instrument_dialog_fires_dialog_callback(
+ def test_a_playing_channel_offers_its_slice(
self,
panel_logic: ReconstructionPanelLogic,
mock_reconstruction_manager: MagicMock,
loaded_data: ReconstructionData,
) -> None:
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)
- callback.assert_called_once()
- def test_request_export_instrument_dialog_suggests_the_slice_name(
+ exportable = panel_logic.exportable_instrument(ChannelName.PULSE1)
+
+ assert exportable is not None
+ assert exportable.source.features is loaded_data.feature_data[ChannelName.PULSE1]
+
+ def test_the_suggestion_is_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
+ """The suggestion is the slice name alone, leaving the format 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,
- mock_reconstruction_manager: MagicMock,
- loaded_data: ReconstructionData,
- ) -> None:
- mock_reconstruction_manager.current_reconstruction = loaded_data
- callback = MagicMock()
- panel_logic.on_open_export_instrument_dialog = callback
- panel_logic.request_export_instrument_dialog(GeneratorName.TRIANGLE)
- callback.assert_not_called()
+ exportable = panel_logic.exportable_instrument(ChannelName.PULSE1)
+
+ assert exportable is not None
+ assert exportable.name == "Sample (pulse1)"
- def test_request_export_instrument_dialog_sends_the_generator_to_the_dialog(
+ def test_a_channel_standing_by_offers_nothing(
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:
- panel_logic.handle_export_instrument_confirmed(
- tmp_path / "instrument.fti",
- GeneratorName.PULSE1,
- )
- mock_export_service.export_instrument.assert_not_called()
+ assert panel_logic.exportable_instrument(ChannelName.TRIANGLE) is None
- def test_handle_export_instrument_confirmed_calls_export_service(
+ def test_the_slice_names_the_channel_it_was_reconstructed_for(
self,
panel_logic: ReconstructionPanelLogic,
mock_reconstruction_manager: MagicMock,
loaded_data: ReconstructionData,
- mock_export_service: MagicMock,
- tmp_path: Path,
) -> None:
+ """A backend sounding the slice on its own plays it through the channel it names."""
mock_reconstruction_manager.current_reconstruction = loaded_data
- 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)"
+ exportable = panel_logic.exportable_instrument(ChannelName.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(
+ assert exportable is not None
+ assert exportable.source.channel == ChannelName.PULSE1
+
+ def test_a_reconstruction_slice_plays_its_envelopes_once(
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:
+ """A loop belongs to a sample placed in a project, so a reconstruction states 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(
+ exportable = panel_logic.exportable_instrument(ChannelName.PULSE1)
+
+ assert exportable is not None
+ assert all(not envelope.loops for envelope in exportable.source.features.envelopes.values())
+
+ def test_the_slice_carries_the_reconstructions_tuning(
self,
panel_logic: ReconstructionPanelLogic,
mock_reconstruction_manager: MagicMock,
- loaded_data: ReconstructionData,
- tmp_path: Path,
- extension: str,
+ retuned_data: ReconstructionData,
) -> 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.
+ """A backend sounding the export itself measures its pitches from the tuning the
+ reconstruction was built with, so the slice states that tuning rather than the standard.
"""
- mock_reconstruction_manager.current_reconstruction = loaded_data
- with pytest.raises(ValueError):
- panel_logic.handle_export_instrument_confirmed(
- tmp_path / f"instrument{extension}",
- GeneratorName.PULSE1,
- )
+ mock_reconstruction_manager.current_reconstruction = retuned_data
+
+ exportable = panel_logic.exportable_instrument(ChannelName.PULSE1)
+
+ assert exportable is not None
+ assert exportable.source.tuning == Tuning(a4_frequency=RETUNED_A4_FREQUENCY)
class TestReconstructionPanelLogicExportInstruments:
@@ -660,7 +698,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(TrackerFormat.FAMITRACKER)
+ panel_logic.request_export_instruments_dialog(ExportFormat.FAMITRACKER)
def test_request_export_instruments_dialog_fires_dialog_callback(
self,
@@ -671,7 +709,7 @@ 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(TrackerFormat.FAMITRACKER)
+ panel_logic.request_export_instruments_dialog(ExportFormat.FAMITRACKER)
callback.assert_called_once()
def test_request_export_instruments_dialog_suggests_the_reconstruction_name(
@@ -686,7 +724,7 @@ def test_request_export_instruments_dialog_suggests_the_reconstruction_name(
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)
+ panel_logic.request_export_instruments_dialog(ExportFormat.FAMITRACKER)
assert callback.call_args.args[0] == f"{loaded_data.name}{EXT_FILE_INSTRUMENT}"
def test_request_export_instruments_dialog_carries_the_chosen_tracker(
@@ -699,8 +737,8 @@ def test_request_export_instruments_dialog_carries_the_chosen_tracker(
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
+ panel_logic.request_export_instruments_dialog(ExportFormat.BITPHASE_PRESET)
+ assert callback.call_args.args[2] == ExportFormat.BITPHASE_PRESET
def test_handle_export_instruments_confirmed_with_no_data_is_no_op(
self,
@@ -710,7 +748,7 @@ def test_handle_export_instruments_confirmed_with_no_data_is_no_op(
) -> None:
panel_logic.handle_export_instruments_confirmed(
tmp_path / "sample.fti",
- TrackerFormat.FAMITRACKER,
+ ExportFormat.FAMITRACKER,
)
mock_export_service.export_sample.assert_not_called()
@@ -725,7 +763,7 @@ def test_handle_export_instruments_confirmed_calls_export_sample(
mock_reconstruction_manager.current_reconstruction = loaded_data
panel_logic.handle_export_instruments_confirmed(
tmp_path / "sample.fti",
- TrackerFormat.FAMITRACKER,
+ ExportFormat.FAMITRACKER,
)
mock_export_service.export_sample.assert_called_once()
@@ -740,12 +778,30 @@ def test_handle_export_instruments_confirmed_names_the_batch_after_the_destinati
mock_reconstruction_manager.current_reconstruction = loaded_data
panel_logic.handle_export_instruments_confirmed(
tmp_path / "Clap.fti",
- TrackerFormat.FAMITRACKER,
+ ExportFormat.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)"]
+ def test_handle_export_instruments_confirmed_carries_the_reconstructions_tuning(
+ self,
+ panel_logic: ReconstructionPanelLogic,
+ mock_reconstruction_manager: MagicMock,
+ retuned_data: ReconstructionData,
+ mock_export_service: MagicMock,
+ tmp_path: Path,
+ ) -> None:
+ mock_reconstruction_manager.current_reconstruction = retuned_data
+ panel_logic.handle_export_instruments_confirmed(
+ tmp_path / "Clap.fti",
+ ExportFormat.FAMITRACKER,
+ )
+ request = mock_export_service.export_sample.call_args.args[2]
+ retuned = Tuning(a4_frequency=RETUNED_A4_FREQUENCY)
+ assert request.tuning == retuned
+ assert [instrument.tuning for instrument in request.instruments] == [retuned]
+
@pytest.mark.parametrize(
"case",
INSTRUMENT_FORMAT_CASES,
@@ -757,7 +813,7 @@ def test_handle_export_instruments_confirmed_writes_through_the_chosen_tracker(
mock_reconstruction_manager: MagicMock,
loaded_data: ReconstructionData,
mock_export_service: MagicMock,
- mock_tracker_backends: Dict[TrackerFormat, MagicMock],
+ mock_export_backends: Dict[ExportFormat, MagicMock],
tmp_path: Path,
case: FormatCase,
) -> None:
@@ -767,10 +823,10 @@ def test_handle_export_instruments_confirmed_writes_through_the_chosen_tracker(
mock_reconstruction_manager.current_reconstruction = loaded_data
panel_logic.handle_export_instruments_confirmed(
tmp_path / f"sample{case.extension}",
- case.tracker_format,
+ case.export_format,
)
backend = mock_export_service.export_sample.call_args.args[1]
- assert backend is mock_tracker_backends[case.tracker_format]
+ assert backend is mock_export_backends[case.export_format]
class TestReconstructionPanelLogicExportWav:
@@ -811,7 +867,7 @@ def test_handle_export_wav_confirmed_calls_export_wav_with_sample_rate(
tmp_path: Path,
) -> None:
mock_reconstruction_manager.current_reconstruction = loaded_data
- panel_logic._selected_generators = [GeneratorName.PULSE1]
+ panel_logic._selected_channels = [ChannelName.PULSE1]
panel_logic.handle_export_wav_confirmed(tmp_path / "output.wav")
mock_export_service.export_wav.assert_called_once()
call_args = mock_export_service.export_wav.call_args
@@ -830,12 +886,12 @@ def test_set_audio_source_with_no_data_emits_none(
class TestReconstructionPanelLogicLocateAudio:
- def test_no_audio_filepath_skips_locating(
+ def test_no_source_paths_skips_locating(
self,
panel_logic: ReconstructionPanelLogic,
mock_reconstruction_manager: MagicMock,
) -> None:
- mock_reconstruction_manager.audio_filepath = None
+ mock_reconstruction_manager.source_paths = ()
panel_logic.handle_locate_original_audio()
mock_reconstruction_manager.locate_original_audio.assert_not_called()
@@ -846,9 +902,162 @@ def test_missing_audio_file_fires_on_locate_audio_not_found(
tmp_path: Path,
) -> None:
missing = tmp_path / "ghost.wav"
- mock_reconstruction_manager.audio_filepath = missing
+ mock_reconstruction_manager.source_paths = (missing,)
mock_reconstruction_manager.locate_original_audio.side_effect = FileNotFoundError
callback = MagicMock()
panel_logic.on_locate_audio_not_found = callback
panel_logic.handle_locate_original_audio()
callback.assert_called_once_with(missing)
+
+
+class TestReconstructionPanelLogicStemSelection:
+ @pytest.fixture(name="stems_data")
+ def stems_data_fixture(
+ self,
+ reconstruction_factory: Callable[[], Reconstruction],
+ tmp_path: Path,
+ ) -> ReconstructionData:
+ reconstruction = reconstruction_factory()
+ frame_count = len(reconstruction.approximations[ChannelName.PULSE1]) // reconstruction.config.frame_length
+ stems_config = StemsConfig(
+ entries=[
+ StemEntry(id=0, channels=[ChannelName.PULSE1]),
+ StemEntry(id=1, channels=[ChannelName.PULSE1]),
+ ],
+ hierarchy=StemsHierarchy(levels=[[0, 1]]),
+ )
+ stems_reconstruction = reconstruction.model_copy(
+ update={
+ "audio_filepath": (tmp_path / "a.wav", tmp_path / "b.wav"),
+ "stems_data": StemsData(
+ config=stems_config,
+ assignments=[
+ ChannelAssignment(
+ channel_name=ChannelName.PULSE1,
+ stem_ids=[0, 1] * (frame_count // 2) + ([0] if frame_count % 2 else []),
+ )
+ ],
+ ),
+ }
+ )
+ return ReconstructionData.from_reconstruction(stems_reconstruction, name="Sample")
+
+ def test_display_hears_every_recording_on_the_channels_it_holds(
+ self,
+ panel_logic: ReconstructionPanelLogic,
+ mock_reconstruction_manager: MagicMock,
+ stems_data: ReconstructionData,
+ ) -> None:
+ mock_reconstruction_manager.current_reconstruction = stems_data
+ stems_views = []
+ panel_logic.on_stems_view_changed = stems_views.append
+
+ panel_logic.display_reconstruction()
+
+ assert panel_logic._stem_channels == {
+ 0: frozenset({ChannelName.PULSE1}),
+ 1: frozenset(),
+ }
+ assert len(stems_views) == 1
+ rows = stems_views[0].stems.rows
+ assert {row.key for row in rows} == {"0", "1"}
+ assert all(row.channels == row.offered_channels for row in rows)
+ assert stems_views[0].stems.channels_in_play == (ChannelName.PULSE1,)
+
+ def test_a_recording_holding_no_frames_offers_no_box(
+ self,
+ panel_logic: ReconstructionPanelLogic,
+ mock_reconstruction_manager: MagicMock,
+ stems_data: ReconstructionData,
+ ) -> None:
+ mock_reconstruction_manager.current_reconstruction = stems_data
+ stems_views = []
+ panel_logic.on_stems_view_changed = stems_views.append
+
+ panel_logic.display_reconstruction()
+
+ rows = {row.key: row for row in stems_views[0].stems.rows}
+ assert rows["0"].offered_channels == frozenset({ChannelName.PULSE1})
+ assert rows["1"].offered_channels == frozenset()
+ assert not rows["1"].offers_channels
+
+ def test_a_row_stands_where_the_hierarchy_put_its_recording(
+ self,
+ panel_logic: ReconstructionPanelLogic,
+ mock_reconstruction_manager: MagicMock,
+ stems_data: ReconstructionData,
+ ) -> None:
+ mock_reconstruction_manager.current_reconstruction = stems_data
+ stems_views = []
+ panel_logic.on_stems_view_changed = stems_views.append
+
+ panel_logic.display_reconstruction()
+
+ rows = stems_views[0].stems.rows
+ assert [(row.key, row.level, row.position) for row in rows] == [("0", 0, 0), ("1", 0, 1)]
+ assert all(row.level_size == 2 and row.level_count == 1 for row in rows)
+
+ def test_silencing_a_recording_filters_waveform_and_playback(
+ self,
+ panel_logic: ReconstructionPanelLogic,
+ mock_reconstruction_manager: MagicMock,
+ stems_data: ReconstructionData,
+ ) -> None:
+ mock_reconstruction_manager.current_reconstruction = stems_data
+ panel_logic.display_reconstruction()
+ waveform_updates = []
+ audio_updates = []
+ panel_logic.on_waveform_load_changed = lambda waveform, channels: waveform_updates.append(waveform)
+ panel_logic.on_audio_data_changed = lambda audio: audio_updates.append(audio)
+
+ panel_logic.set_stem_channels(0, frozenset())
+
+ expected = stems_data.partials_for(
+ panel_logic._selected_channels,
+ panel_logic._stem_selection,
+ )
+ assert len(waveform_updates) == 1
+ np.testing.assert_allclose(waveform_updates[0].partials(panel_logic._selected_channels), expected)
+ assert len(audio_updates) == 1
+ assert audio_updates[0] is not None
+ np.testing.assert_allclose(audio_updates[0].sample, expected)
+
+ def test_a_channel_switched_off_for_everything_mutes_its_column(
+ self,
+ panel_logic: ReconstructionPanelLogic,
+ mock_reconstruction_manager: MagicMock,
+ stems_data: ReconstructionData,
+ ) -> None:
+ mock_reconstruction_manager.current_reconstruction = stems_data
+ panel_logic.display_reconstruction()
+ stems_views = []
+ panel_logic.on_stems_view_changed = stems_views.append
+
+ panel_logic.set_selected_channels([])
+
+ assert len(stems_views) == 1
+ rows = {row.key: row for row in stems_views[0].stems.rows}
+ assert stems_views[0].stems.muted_channels == frozenset({ChannelName.PULSE1})
+ assert rows["0"].channels == frozenset({ChannelName.PULSE1})
+
+ def test_export_wav_uses_the_stems_filter(
+ self,
+ panel_logic: ReconstructionPanelLogic,
+ mock_reconstruction_manager: MagicMock,
+ mock_export_service: MagicMock,
+ stems_data: ReconstructionData,
+ tmp_path: Path,
+ ) -> None:
+ mock_reconstruction_manager.current_reconstruction = stems_data
+ panel_logic.display_reconstruction()
+ panel_logic.set_stem_channels(0, frozenset())
+
+ panel_logic.handle_export_wav_confirmed(tmp_path / "output.wav")
+
+ mock_export_service.export_wav.assert_called_once()
+ exported_audio = mock_export_service.export_wav.call_args.args[2]
+ expected = stems_data.partials_for(
+ panel_logic._selected_channels,
+ panel_logic._stem_selection,
+ )
+ np.testing.assert_allclose(exported_audio, expected)
diff --git a/tests/unit/sampletones_application/logic/render/test_logic.py b/tests/unit/sampletones_application/logic/render/test_logic.py
index df2ee2414..633526a31 100644
--- a/tests/unit/sampletones_application/logic/render/test_logic.py
+++ b/tests/unit/sampletones_application/logic/render/test_logic.py
@@ -13,7 +13,7 @@
)
from sampletones_application.services.render.result import RenderStage
from sampletones_application.services.result import (
- ServiceCancelled,
+ ServiceCanceled,
ServiceError,
ServiceProgress,
ServiceSuccess,
@@ -275,14 +275,14 @@ def test_a_finished_render_reports_the_file_it_wrote(self, render: RenderFixture
def test_a_stopped_render_reports_the_cancellation(self, render: RenderFixture) -> None:
render.configure()
render.logic.start()
- on_cancelled = MagicMock()
- render.logic.on_cancelled = on_cancelled
+ on_canceled = MagicMock()
+ render.logic.on_canceled = on_canceled
render.logic.cancel()
- render.service.emit(ServiceCancelled())
+ render.service.emit(ServiceCanceled())
- on_cancelled.assert_called_once()
- assert render.view.phase == RenderPhase.CANCELLED
+ on_canceled.assert_called_once()
+ assert render.view.phase == RenderPhase.CANCELED
assert not render.logic.is_active
def test_a_failed_render_reports_what_went_wrong(self, render: RenderFixture) -> None:
diff --git a/tests/unit/sampletones_application/logic/sequencer/clipboard/test_samples.py b/tests/unit/sampletones_application/logic/sequencer/clipboard/test_samples.py
index 6e374b736..48c57497a 100644
--- a/tests/unit/sampletones_application/logic/sequencer/clipboard/test_samples.py
+++ b/tests/unit/sampletones_application/logic/sequencer/clipboard/test_samples.py
@@ -5,7 +5,7 @@
from sampletones_application.logic.sequencer.clipboard.samples import (
ProjectSampleDirectory,
)
-from sampletones_core.constants.enums import GeneratorName
+from sampletones_core.constants.enums import ChannelName
from tests.suite.sequencer import sample_reconstruction
@@ -23,7 +23,7 @@ def directory(controller: ProjectController) -> ProjectSampleDirectory:
def _add_sample(controller: ProjectController, name: str) -> str:
sample = controller.add_sample(
- sample_reconstruction([GeneratorName.PULSE1]),
+ sample_reconstruction([ChannelName.PULSE1]),
name=name,
)
return sample.id
diff --git a/tests/unit/sampletones_application/logic/sequencer/clipboard/test_tracker.py b/tests/unit/sampletones_application/logic/sequencer/clipboard/test_tracker.py
index 2a20eb0dc..92e92ec80 100644
--- a/tests/unit/sampletones_application/logic/sequencer/clipboard/test_tracker.py
+++ b/tests/unit/sampletones_application/logic/sequencer/clipboard/test_tracker.py
@@ -8,8 +8,9 @@
from sampletones_application.view_model.sequencer.region import TrackerRegion
from sampletones_application.view_model.sequencer.slot import TrackerSlot
from sampletones_application.view_model.sequencer.subcolumn import SubColumn
-from sampletones_core.constants.enums import GeneratorName
-from sampletones_core.project.instruments.note_off import NoteOff
+from sampletones_core.constants.enums import ChannelName
+from sampletones_core.constants.general import MAX_TRANSPOSE, MIN_TRANSPOSE
+from sampletones_core.project.voices.note_off import NoteOff
SAMPLE_IDS: List[str] = ["kick", "snare", "hat"]
@@ -17,18 +18,18 @@
class FakeSampleDirectory:
"""A list of samples, standing where the project's own list would."""
- def __init__(self, sample_ids: List[str]) -> None:
- self._sample_ids = sample_ids
+ def __init__(self, voice_ids: List[str]) -> None:
+ self._voice_ids = voice_ids
- def position_of(self, sample_id: str) -> Optional[int]:
- if sample_id not in self._sample_ids:
+ def position_of(self, voice_id: str) -> Optional[int]:
+ if voice_id not in self._voice_ids:
return None
- return self._sample_ids.index(sample_id)
+ return self._voice_ids.index(voice_id)
def sample_at(self, position: int) -> Optional[str]:
- if 0 <= position < len(self._sample_ids):
- return self._sample_ids[position]
+ if 0 <= position < len(self._voice_ids):
+ return self._voice_ids[position]
return None
@@ -38,8 +39,8 @@ def text() -> TrackerBlockText:
return TrackerBlockText(samples=FakeSampleDirectory(SAMPLE_IDS))
-def _slot(generator: Optional[GeneratorName], subcolumn: SubColumn) -> int:
- return TrackerSlot(generator, subcolumn).flat_index
+def _slot(channel: Optional[ChannelName], subcolumn: SubColumn) -> int:
+ return TrackerSlot(channel, subcolumn).flat_index
def _region(
@@ -57,8 +58,8 @@ def _region(
PULSE1_CELL = _region(
- first_slot=_slot(GeneratorName.PULSE1, SubColumn.INSTRUMENT),
- last_slot=_slot(GeneratorName.PULSE1, SubColumn.VOLUME),
+ first_slot=_slot(ChannelName.PULSE1, SubColumn.VOICE),
+ last_slot=_slot(ChannelName.PULSE1, SubColumn.VOLUME),
)
@@ -104,8 +105,8 @@ def test_a_note_naming_a_sample_the_list_lacks_prints_as_mixed(self, text: Track
class TestTheShapeAStatementCovers:
def test_a_header_opens_the_text_with_the_grid_and_the_slots(self, text: TrackerBlockText) -> None:
region = _region(
- first_slot=_slot(GeneratorName.PULSE1, SubColumn.INSTRUMENT),
- last_slot=_slot(GeneratorName.PULSE2, SubColumn.VOLUME),
+ first_slot=_slot(ChannelName.PULSE1, SubColumn.VOICE),
+ last_slot=_slot(ChannelName.PULSE2, SubColumn.VOLUME),
rows=4,
)
@@ -115,8 +116,8 @@ def test_a_header_opens_the_text_with_the_grid_and_the_slots(self, text: Tracker
def test_a_bar_stands_between_the_columns_a_row_crosses(self, text: TrackerBlockText) -> None:
region = _region(
- first_slot=_slot(GeneratorName.PULSE1, SubColumn.INSTRUMENT),
- last_slot=_slot(GeneratorName.PULSE2, SubColumn.VOLUME),
+ first_slot=_slot(ChannelName.PULSE1, SubColumn.VOICE),
+ last_slot=_slot(ChannelName.PULSE2, SubColumn.VOLUME),
)
assert _body(text, TrackerBlock(notes={}, transposes={}, volumes={}), region) == ["?? ??? ? | ?? ??? ?"]
@@ -124,8 +125,8 @@ def test_a_bar_stands_between_the_columns_a_row_crosses(self, text: TrackerBlock
def test_a_row_of_the_block_prints_a_line_of_its_own(self, text: TrackerBlockText) -> None:
block = TrackerBlock(notes={}, transposes={(0, 1): 1, (2, 1): 3}, volumes={})
region = _region(
- first_slot=_slot(GeneratorName.PULSE1, SubColumn.INSTRUMENT),
- last_slot=_slot(GeneratorName.PULSE1, SubColumn.VOLUME),
+ first_slot=_slot(ChannelName.PULSE1, SubColumn.VOICE),
+ last_slot=_slot(ChannelName.PULSE1, SubColumn.VOLUME),
rows=3,
)
@@ -149,8 +150,8 @@ class RoundTripCase:
"a cut and an empty note",
TrackerBlock(notes={(0, 0): NoteOff(), (1, 0): None}, transposes={}, volumes={}),
_region(
- first_slot=_slot(GeneratorName.PULSE1, SubColumn.INSTRUMENT),
- last_slot=_slot(GeneratorName.PULSE1, SubColumn.VOLUME),
+ first_slot=_slot(ChannelName.PULSE1, SubColumn.VOICE),
+ last_slot=_slot(ChannelName.PULSE1, SubColumn.VOLUME),
rows=2,
),
),
@@ -158,8 +159,8 @@ class RoundTripCase:
"the whole transpose range",
TrackerBlock(notes={}, transposes={(0, 1): -24, (1, 1): 36, (2, 1): 0}, volumes={}),
_region(
- first_slot=_slot(GeneratorName.PULSE1, SubColumn.INSTRUMENT),
- last_slot=_slot(GeneratorName.PULSE1, SubColumn.VOLUME),
+ first_slot=_slot(ChannelName.PULSE1, SubColumn.VOICE),
+ last_slot=_slot(ChannelName.PULSE1, SubColumn.VOLUME),
rows=3,
),
),
@@ -167,8 +168,8 @@ class RoundTripCase:
"the whole volume range",
TrackerBlock(notes={}, transposes={}, volumes={(0, 2): 0, (1, 2): 15}),
_region(
- first_slot=_slot(GeneratorName.PULSE1, SubColumn.INSTRUMENT),
- last_slot=_slot(GeneratorName.PULSE1, SubColumn.VOLUME),
+ first_slot=_slot(ChannelName.PULSE1, SubColumn.VOICE),
+ last_slot=_slot(ChannelName.PULSE1, SubColumn.VOLUME),
rows=2,
),
),
@@ -176,24 +177,24 @@ class RoundTripCase:
"a block anchored at the sample column",
TrackerBlock(notes={(0, 0): "hat"}, transposes={(0, 4): 2}, volumes={(0, 5): 9}),
_region(
- first_slot=_slot(None, SubColumn.INSTRUMENT),
- last_slot=_slot(GeneratorName.PULSE1, SubColumn.VOLUME),
+ first_slot=_slot(None, SubColumn.VOICE),
+ last_slot=_slot(ChannelName.PULSE1, SubColumn.VOLUME),
),
),
RoundTripCase(
"a block starting and ending mid-cell",
TrackerBlock(notes={(0, 3): "snare"}, transposes={(0, 1): 5, (0, 4): None}, volumes={(0, 2): 3}),
_region(
- first_slot=_slot(GeneratorName.PULSE1, SubColumn.TRANSPOSE),
- last_slot=_slot(GeneratorName.PULSE2, SubColumn.TRANSPOSE),
+ first_slot=_slot(ChannelName.PULSE1, SubColumn.TRANSPOSE),
+ last_slot=_slot(ChannelName.PULSE2, SubColumn.TRANSPOSE),
),
),
RoundTripCase(
"the whole grid",
TrackerBlock(notes={(0, 12): "kick"}, transposes={(1, 1): -1}, volumes={(1, 14): 4}),
_region(
- first_slot=_slot(None, SubColumn.INSTRUMENT),
- last_slot=_slot(GeneratorName.NOISE, SubColumn.VOLUME),
+ first_slot=_slot(None, SubColumn.VOICE),
+ last_slot=_slot(ChannelName.NOISE, SubColumn.VOLUME),
rows=2,
),
),
@@ -270,8 +271,8 @@ class RefusalCase:
RefusalCase("a slot past the grid", "SampleToNES/1 tracker rows=1 slots=13..15\n01 +00 F"),
RefusalCase("a word in a note field", f"{HEADER}\nxx +00 F\n01 +00 F"),
RefusalCase("an unsigned transpose", f"{HEADER}\n01 12 F\n01 +00 F"),
- RefusalCase("a transpose past the range", f"{HEADER}\n01 +40 F\n01 +00 F"),
- RefusalCase("a transpose below the range", f"{HEADER}\n01 -40 F\n01 +00 F"),
+ RefusalCase("a transpose past the range", f"{HEADER}\n01 +{MAX_TRANSPOSE + 1:02X} F\n01 +00 F"),
+ RefusalCase("a transpose below the range", f"{HEADER}\n01 -{abs(MIN_TRANSPOSE) + 1:02X} F\n01 +00 F"),
RefusalCase("a volume past the range", f"{HEADER}\n01 +00 FF\n01 +00 F"),
RefusalCase("dots and marks in one field", f"{HEADER}\n.? +00 F\n01 +00 F"),
]
diff --git a/tests/unit/sampletones_application/logic/sequencer/order/test_order.py b/tests/unit/sampletones_application/logic/sequencer/order/test_order.py
index ec24ec9fc..94eaa7f60 100644
--- a/tests/unit/sampletones_application/logic/sequencer/order/test_order.py
+++ b/tests/unit/sampletones_application/logic/sequencer/order/test_order.py
@@ -1,51 +1,51 @@
from sampletones_application.logic.project.controller import ProjectController
from sampletones_application.logic.project.manager import ProjectManager
from sampletones_application.logic.sequencer.order import SequencerOrderLogic
-from sampletones_core.constants.enums import GeneratorName
+from sampletones_core.constants.enums import ChannelName
def _logic() -> SequencerOrderLogic:
return SequencerOrderLogic(ProjectController(ProjectManager()))
-def _order_column(logic: SequencerOrderLogic, generator: GeneratorName) -> list:
- """Returns the list of pattern indices for ``generator`` across all order positions."""
+def _order_column(logic: SequencerOrderLogic, channel: ChannelName) -> list:
+ """Returns the list of pattern indices for ``channel`` across all order positions."""
song = logic._controller.project.song
- return [frame[generator] for frame in song.order]
+ return [frame[channel] for frame in song.order]
class TestOrderMutations:
def test_set_order_entry_assigns_one_channel(self) -> None:
logic = _logic()
- logic.set_order_entry(GeneratorName.PULSE1, 0, 3)
+ logic.set_order_entry(ChannelName.PULSE1, 0, 3)
- assert _order_column(logic, GeneratorName.PULSE1) == [3]
- assert _order_column(logic, GeneratorName.TRIANGLE) == [0]
+ assert _order_column(logic, ChannelName.PULSE1) == [3]
+ assert _order_column(logic, ChannelName.TRIANGLE) == [0]
def test_set_master_entry_broadcasts_to_every_channel(self) -> None:
logic = _logic()
logic.set_master_entry(0, 4)
- for generator in GeneratorName.items():
- assert _order_column(logic, generator) == [4]
+ for channel in ChannelName.items():
+ assert _order_column(logic, channel) == [4]
def test_set_order_entry_clears_one_channel_with_none(self) -> None:
logic = _logic()
- logic.set_order_entry(GeneratorName.PULSE1, 0, None)
+ logic.set_order_entry(ChannelName.PULSE1, 0, None)
- assert _order_column(logic, GeneratorName.PULSE1) == [None]
- assert _order_column(logic, GeneratorName.TRIANGLE) == [0]
+ assert _order_column(logic, ChannelName.PULSE1) == [None]
+ assert _order_column(logic, ChannelName.TRIANGLE) == [0]
def test_set_master_entry_clears_every_channel_with_none(self) -> None:
logic = _logic()
logic.set_master_entry(0, None)
- for generator in GeneratorName.items():
- assert _order_column(logic, generator) == [None]
+ for channel in ChannelName.items():
+ assert _order_column(logic, channel) == [None]
def test_remove_from_order_drops_the_frame(self) -> None:
logic = _logic()
@@ -53,8 +53,8 @@ def test_remove_from_order_drops_the_frame(self) -> None:
logic.remove_from_order(0)
- for generator in GeneratorName.items():
- assert _order_column(logic, generator) == [None]
+ for channel in ChannelName.items():
+ assert _order_column(logic, channel) == [None]
class TestEntryAccess:
@@ -63,29 +63,29 @@ class TestEntryAccess:
def test_write_entry_reaches_one_channel(self) -> None:
logic = _logic()
- logic.write_entry(GeneratorName.PULSE1, 0, 3)
+ logic.write_entry(ChannelName.PULSE1, 0, 3)
- assert _order_column(logic, GeneratorName.PULSE1) == [3]
- assert _order_column(logic, GeneratorName.TRIANGLE) == [0]
+ assert _order_column(logic, ChannelName.PULSE1) == [3]
+ assert _order_column(logic, ChannelName.TRIANGLE) == [0]
def test_write_entry_through_the_master_row_reaches_every_channel(self) -> None:
logic = _logic()
logic.write_entry(None, 0, 3)
- for generator in GeneratorName.items():
- assert _order_column(logic, generator) == [3]
+ for channel in ChannelName.items():
+ assert _order_column(logic, channel) == [3]
def test_entry_reads_the_index_a_channel_plays(self) -> None:
logic = _logic()
- logic.set_order_entry(GeneratorName.NOISE, 0, 7)
+ logic.set_order_entry(ChannelName.NOISE, 0, 7)
- assert logic.entry(GeneratorName.NOISE, 0) == 7
+ assert logic.entry(ChannelName.NOISE, 0) == 7
def test_entry_past_the_last_frame_reads_as_silence(self) -> None:
logic = _logic()
- assert logic.entry(GeneratorName.NOISE, logic.position_count()) is None
+ assert logic.entry(ChannelName.NOISE, logic.position_count()) is None
def test_append_frame_lengthens_the_order_by_one(self) -> None:
logic = _logic()
@@ -94,34 +94,34 @@ def test_append_frame_lengthens_the_order_by_one(self) -> None:
logic.append_frame()
assert logic.position_count() == length + 1
- for generator in GeneratorName.items():
- assert logic.entry(generator, length) is None
+ for channel in ChannelName.items():
+ assert logic.entry(channel, length) is None
class TestOrderFrameOps:
def test_insert_frame_adds_empty_frame_at_position(self) -> None:
logic = _logic()
- logic.set_order_entry(GeneratorName.PULSE1, 0, 5)
+ logic.set_order_entry(ChannelName.PULSE1, 0, 5)
logic.insert_frame(0)
- assert _order_column(logic, GeneratorName.PULSE1) == [None, 5]
+ assert _order_column(logic, ChannelName.PULSE1) == [None, 5]
def test_duplicate_frame_repeats_the_same_pattern(self) -> None:
logic = _logic()
- logic.set_order_entry(GeneratorName.PULSE1, 0, 5)
+ logic.set_order_entry(ChannelName.PULSE1, 0, 5)
logic.duplicate_frame(0)
- assert _order_column(logic, GeneratorName.PULSE1) == [5, 5]
+ assert _order_column(logic, ChannelName.PULSE1) == [5, 5]
def test_clone_frame_gives_the_copy_its_own_pattern(self) -> None:
logic = _logic()
- logic.set_order_entry(GeneratorName.PULSE1, 0, 5)
+ logic.set_order_entry(ChannelName.PULSE1, 0, 5)
logic.clone_frame(0)
- source_index, clone_index = _order_column(logic, GeneratorName.PULSE1)
+ source_index, clone_index = _order_column(logic, ChannelName.PULSE1)
assert source_index == 5
assert clone_index != 5
@@ -131,18 +131,18 @@ def test_clear_frame_empties_every_channel(self) -> None:
logic.clear_frame(0)
- for generator in GeneratorName.items():
- assert _order_column(logic, generator) == [None]
+ for channel in ChannelName.items():
+ assert _order_column(logic, channel) == [None]
def test_move_frame_reorders(self) -> None:
logic = _logic()
logic.insert_frame(1)
- logic.set_order_entry(GeneratorName.PULSE1, 0, 1)
- logic.set_order_entry(GeneratorName.PULSE1, 1, 2)
+ logic.set_order_entry(ChannelName.PULSE1, 0, 1)
+ logic.set_order_entry(ChannelName.PULSE1, 1, 2)
logic.move_frame(0, 1)
- assert _order_column(logic, GeneratorName.PULSE1) == [2, 1]
+ assert _order_column(logic, ChannelName.PULSE1) == [2, 1]
class TestBuildOrder:
@@ -153,5 +153,5 @@ def test_position_count_matches_order_length(self) -> None:
view_model = logic.build_order()
assert view_model.position_count == 2
- assert set(view_model.channels) == set(GeneratorName.items())
- assert view_model.entry_label(GeneratorName.PULSE1, 1) == view_model.master_label(1)
+ assert set(view_model.channels) == set(ChannelName.items())
+ assert view_model.entry_label(ChannelName.PULSE1, 1) == view_model.master_label(1)
diff --git a/tests/unit/sampletones_application/logic/sequencer/order/test_reader.py b/tests/unit/sampletones_application/logic/sequencer/order/test_reader.py
index 2c49e3cc2..636e33554 100644
--- a/tests/unit/sampletones_application/logic/sequencer/order/test_reader.py
+++ b/tests/unit/sampletones_application/logic/sequencer/order/test_reader.py
@@ -10,12 +10,12 @@
SequencerOrderLogic,
)
from sampletones_application.view_model.sequencer.region import OrderRegion
-from sampletones_core.constants.enums import GeneratorName
+from sampletones_core.constants.enums import ChannelName
from tests.suite.sequencer import fill_order
MASTER_ROW = CHANNEL_AXIS.index(None)
-PULSE1_ROW = CHANNEL_AXIS.index(GeneratorName.PULSE1)
-NOISE_ROW = CHANNEL_AXIS.index(GeneratorName.NOISE)
+PULSE1_ROW = CHANNEL_AXIS.index(ChannelName.PULSE1)
+NOISE_ROW = CHANNEL_AXIS.index(ChannelName.NOISE)
@pytest.fixture
@@ -30,12 +30,12 @@ def reader(logic: SequencerOrderLogic) -> OrderBlockReader:
def _row(
- generator: Optional[GeneratorName],
+ channel: Optional[ChannelName],
*,
last_position: int = 0,
) -> OrderRegion:
"""The region one whole row covers, out to ``last_position``."""
- row = CHANNEL_AXIS.index(generator)
+ row = CHANNEL_AXIS.index(channel)
return OrderRegion(
first_row=row,
last_row=row,
@@ -62,7 +62,7 @@ def test_a_row_carries_the_indices_it_plays(
),
)
- block = reader.read(_row(GeneratorName.PULSE1, last_position=2))
+ block = reader.read(_row(ChannelName.PULSE1, last_position=2))
assert block.entries == {(0, 0): 0, (0, 1): 1, (0, 2): 2}
@@ -82,7 +82,7 @@ def test_a_silent_cell_carries_its_silence(
),
)
- block = reader.read(_row(GeneratorName.NOISE))
+ block = reader.read(_row(ChannelName.NOISE))
assert block.entries == {(0, 0): None}
@@ -170,7 +170,7 @@ def test_offsets_run_from_the_cell_the_region_begins_at(
block = reader.read(
OrderRegion(
first_row=PULSE1_ROW,
- last_row=CHANNEL_AXIS.index(GeneratorName.PULSE2),
+ last_row=CHANNEL_AXIS.index(ChannelName.PULSE2),
first_position=2,
last_position=2,
)
diff --git a/tests/unit/sampletones_application/logic/sequencer/order/test_writer.py b/tests/unit/sampletones_application/logic/sequencer/order/test_writer.py
index 9ee95980f..551d4e73b 100644
--- a/tests/unit/sampletones_application/logic/sequencer/order/test_writer.py
+++ b/tests/unit/sampletones_application/logic/sequencer/order/test_writer.py
@@ -12,7 +12,7 @@
SequencerOrderLogic,
)
from sampletones_application.view_model.sequencer.region import OrderCell, OrderRegion
-from sampletones_core.constants.enums import GeneratorName
+from sampletones_core.constants.enums import ChannelName
from tests.suite.base import BaseTestSuite
from tests.suite.case import BaseRegularTestCase
from tests.suite.sequencer import fill_order, parse_order_block, render_order
@@ -50,8 +50,8 @@ def table() -> Table:
)
-def _row(generator: Optional[GeneratorName]) -> int:
- return CHANNEL_AXIS.index(generator)
+def _row(channel: Optional[ChannelName]) -> int:
+ return CHANNEL_AXIS.index(channel)
class TestPaste(BaseTestSuite):
@@ -72,7 +72,7 @@ class TestCase(BaseRegularTestCase):
TestCase(
label="a block lands at the cell it is written from",
block=("07 08",),
- origin=OrderCell(generator=GeneratorName.PULSE2, position=1),
+ origin=OrderCell(channel=ChannelName.PULSE2, position=1),
expected=(
SILENT,
".. 07 08",
@@ -83,7 +83,7 @@ class TestCase(BaseRegularTestCase):
TestCase(
label="a block through the master row reaches every channel",
block=("05",),
- origin=OrderCell(generator=None, position=0),
+ origin=OrderCell(channel=None, position=0),
expected=(
"05 .. ..",
"05 .. ..",
@@ -97,7 +97,7 @@ class TestCase(BaseRegularTestCase):
"05",
"06",
),
- origin=OrderCell(generator=None, position=0),
+ origin=OrderCell(channel=None, position=0),
expected=(
"06 .. ..",
"05 .. ..",
@@ -108,7 +108,7 @@ class TestCase(BaseRegularTestCase):
TestCase(
label="a block read from the master row writes one channel when written to one",
block=("05",),
- origin=OrderCell(generator=GeneratorName.TRIANGLE, position=2),
+ origin=OrderCell(channel=ChannelName.TRIANGLE, position=2),
expected=(
SILENT,
SILENT,
@@ -117,7 +117,7 @@ class TestCase(BaseRegularTestCase):
),
),
TestCase(
- label="a mixed cell leaves its target as it stands while its neighbours take theirs",
+ label="a mixed cell leaves its target as it stands while its neighbors take theirs",
order=(
"01 02 03",
SILENT,
@@ -125,7 +125,7 @@ class TestCase(BaseRegularTestCase):
SILENT,
),
block=("09 ? 0A",),
- origin=OrderCell(generator=GeneratorName.PULSE1, position=0),
+ origin=OrderCell(channel=ChannelName.PULSE1, position=0),
expected=(
"09 02 0A",
SILENT,
@@ -142,7 +142,7 @@ class TestCase(BaseRegularTestCase):
SILENT,
),
block=(".. ..",),
- origin=OrderCell(generator=GeneratorName.PULSE1, position=0),
+ origin=OrderCell(channel=ChannelName.PULSE1, position=0),
expected=(
".. .. 03",
SILENT,
@@ -157,7 +157,7 @@ class TestCase(BaseRegularTestCase):
"02",
"03",
),
- origin=OrderCell(generator=GeneratorName.TRIANGLE, position=0),
+ origin=OrderCell(channel=ChannelName.TRIANGLE, position=0),
expected=(
SILENT,
SILENT,
@@ -171,7 +171,7 @@ class TestCase(BaseRegularTestCase):
"01",
"02",
),
- origin=OrderCell(generator=GeneratorName.NOISE, position=0),
+ origin=OrderCell(channel=ChannelName.NOISE, position=0),
expected=(
SILENT,
SILENT,
@@ -211,7 +211,7 @@ class TestCase(BaseRegularTestCase):
TestCase(
label="a block reaching past the end appends exactly the positions it writes",
block=("01 02 03",),
- origin=OrderCell(generator=GeneratorName.PULSE1, position=2),
+ origin=OrderCell(channel=ChannelName.PULSE1, position=2),
expected=(
".. .. 01 02 03",
".. .. .. .. ..",
@@ -222,7 +222,7 @@ class TestCase(BaseRegularTestCase):
TestCase(
label="a column the block says nothing about appends no position",
block=("01 ? ?",),
- origin=OrderCell(generator=GeneratorName.PULSE1, position=2),
+ origin=OrderCell(channel=ChannelName.PULSE1, position=2),
expected=(
".. .. 01",
SILENT,
@@ -233,7 +233,7 @@ class TestCase(BaseRegularTestCase):
TestCase(
label="a column the block silences appends the position it silences",
block=("01 ? ..",),
- origin=OrderCell(generator=GeneratorName.PULSE1, position=2),
+ origin=OrderCell(channel=ChannelName.PULSE1, position=2),
expected=(
".. .. 01 .. ..",
".. .. .. .. ..",
@@ -247,7 +247,7 @@ class TestCase(BaseRegularTestCase):
"01 ?",
"? 02",
),
- origin=OrderCell(generator=GeneratorName.NOISE, position=2),
+ origin=OrderCell(channel=ChannelName.NOISE, position=2),
expected=(
SILENT,
SILENT,
@@ -258,7 +258,7 @@ class TestCase(BaseRegularTestCase):
TestCase(
label="a wholly mixed block leaves the order the length it was",
block=("? ? ?",),
- origin=OrderCell(generator=GeneratorName.PULSE1, position=2),
+ origin=OrderCell(channel=ChannelName.PULSE1, position=2),
expected=(
SILENT,
SILENT,
@@ -299,8 +299,8 @@ def test_a_region_silences_the_cells_it_covers(self, table: Table) -> None:
table.writer.clear(
OrderRegion(
- first_row=_row(GeneratorName.PULSE2),
- last_row=_row(GeneratorName.TRIANGLE),
+ first_row=_row(ChannelName.PULSE2),
+ last_row=_row(ChannelName.TRIANGLE),
first_position=0,
last_position=1,
)
@@ -355,7 +355,7 @@ def test_a_delete_leaves_the_order_the_length_it_was(self, table: Table) -> None
table.writer.clear(
OrderRegion(
first_row=_row(None),
- last_row=_row(GeneratorName.NOISE),
+ last_row=_row(ChannelName.NOISE),
first_position=0,
last_position=2,
)
@@ -379,14 +379,14 @@ def test_a_block_written_back_at_its_origin_restores_the_order(self, table: Tabl
)
before = render_order(table.logic)
region = OrderRegion(
- first_row=_row(GeneratorName.PULSE1),
- last_row=_row(GeneratorName.NOISE),
+ first_row=_row(ChannelName.PULSE1),
+ last_row=_row(ChannelName.NOISE),
first_position=0,
last_position=2,
)
block = OrderBlockReader(table.logic).read(region)
table.writer.clear(region)
- table.writer.write(block, OrderCell(generator=GeneratorName.PULSE1, position=0))
+ table.writer.write(block, OrderCell(channel=ChannelName.PULSE1, position=0))
assert render_order(table.logic) == before
diff --git a/tests/unit/sampletones_application/logic/sequencer/playback/conftest.py b/tests/unit/sampletones_application/logic/sequencer/playback/conftest.py
index 7d65e04f8..04fbfd77e 100644
--- a/tests/unit/sampletones_application/logic/sequencer/playback/conftest.py
+++ b/tests/unit/sampletones_application/logic/sequencer/playback/conftest.py
@@ -1,5 +1,5 @@
from pathlib import Path
-from typing import Callable, FrozenSet, Iterable
+from typing import Callable, Final, FrozenSet, Iterable
import numpy as np
import pytest
@@ -10,23 +10,22 @@
from sampletones_application.logic.sequencer.playback.synthesizer import RowSynthesizer
from sampletones_core.configs import Config
from sampletones_core.constants.audio import DEFAULT_SAMPLE_RATE
-from sampletones_core.constants.enums import FeatureKey, GeneratorName
-from sampletones_core.instructions import (
- NoiseInstruction,
- PulseInstruction,
- TriangleInstruction,
-)
-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.constants.enums import ChannelName
+from sampletones_core.project.voices.creation import new_instrument
+from sampletones_core.project.voices.instrument import Instrument
+from sampletones_core.project.voices.note_off import NoteOff
+from sampletones_core.project.voices.note_on import NoteOn
+from sampletones_core.project.voices.sample import Sample
from sampletones_core.reconstructions import Reconstruction
+SOUNDING_FRAMES: Final[int] = 16
+
def make_controller() -> ProjectController:
return ProjectController(ProjectManager())
-def all_channels() -> FrozenSet[GeneratorName]:
+def all_channels() -> FrozenSet[ChannelName]:
"""The fully audible mask a synthesiser renders under unless a test moves it."""
return ALL_CHANNELS
@@ -36,7 +35,7 @@ def make_synthesizer(
config: Config,
*,
sample_rate: int = DEFAULT_SAMPLE_RATE,
- active_channels: Callable[[], FrozenSet[GeneratorName]] = all_channels,
+ active_channels: Callable[[], FrozenSet[ChannelName]] = all_channels,
) -> RowSynthesizer:
"""A synthesiser rendering at ``sample_rate``, standing in for the output a caller supplies."""
return RowSynthesizer(
@@ -47,100 +46,39 @@ def make_synthesizer(
)
-def make_pulse_reconstruction(
- *,
- pitch: int = 60,
- volume: int = 15,
- count: int = 1,
- held_features: Iterable[FeatureKey] = (),
-) -> Reconstruction:
- """Single-generator reconstruction with ``count`` identical PulseInstructions.
-
- ``held_features`` names the dimensions the instrument leaves to the channel, which is what
- an envelope cleared in the instruments panel produces.
- """
- instructions = [PulseInstruction(on=True, pitch=pitch, volume=volume, duty_cycle=0)] * count
- reconstruction = Reconstruction.create(
- approximation=np.zeros(64, dtype=np.float32),
- approximations={GeneratorName.PULSE1: np.zeros(64, dtype=np.float32)},
- instructions={GeneratorName.PULSE1: instructions},
- config=Config(),
- coefficient=1.0,
- audio_filepath=Path("/dev/null"),
- )
- if held_features:
- reconstruction.update_generator_data(
- GeneratorName.PULSE1,
- list(instructions),
- np.zeros(64, dtype=np.float32),
- reconstruction.initial_pitches[GeneratorName.PULSE1],
- held_features,
- )
-
- return reconstruction
-
-
-def make_triangle_reconstruction(
- *,
- pitch: int = 60,
- count: int = 1,
-) -> Reconstruction:
- instructions = [TriangleInstruction(on=True, pitch=pitch)] * count
- return Reconstruction.create(
- approximation=np.zeros(64, dtype=np.float32),
- approximations={GeneratorName.TRIANGLE: np.zeros(64, dtype=np.float32)},
- instructions={GeneratorName.TRIANGLE: instructions},
- config=Config(),
- coefficient=1.0,
- audio_filepath=Path("/dev/null"),
- )
-
-
-def make_noise_reconstruction(
- *,
- period: int = 3,
- volume: int = 15,
- count: int = 1,
-) -> Reconstruction:
- instructions = [NoiseInstruction(on=True, period=period, volume=volume, short=False)] * count
- return Reconstruction.create(
- approximation=np.zeros(64, dtype=np.float32),
- approximations={GeneratorName.NOISE: np.zeros(64, dtype=np.float32)},
- instructions={GeneratorName.NOISE: instructions},
- config=Config(),
- coefficient=1.0,
- audio_filepath=Path("/dev/null"),
- )
-
-
def add_sample(
controller: ProjectController,
reconstruction: Reconstruction,
*,
- loop: bool = False,
name: str = "test",
) -> Sample:
- sample = controller.add_sample(reconstruction, name)
- if loop:
- controller.set_sample_loop(sample.id, loop=True)
- return sample
+ return controller.add_sample(reconstruction, name)
+
+
+def add_instrument(
+ controller: ProjectController,
+ *,
+ name: str = "test",
+) -> Instrument:
+ """A hand-written voice sustaining at full volume, sounding for as long as a row holds it."""
+ return controller.add_instrument(new_instrument(name))
def place_row(
controller: ProjectController,
*,
- generator: GeneratorName,
+ channel: ChannelName,
row_index: int = 0,
- sample_id: str,
+ voice_id: str,
transpose: int | None = None,
volume: int | None = None,
) -> None:
- pattern_index = controller.project.song.order[0][generator]
+ pattern_index = controller.project.song.order[0][channel]
controller.set_row(
- generator,
+ channel,
pattern_index,
row_index,
- command=Instrument(sample_id=sample_id, generator_name=generator),
+ command=NoteOn(voice_id=voice_id),
transpose=transpose,
volume=volume,
)
@@ -149,26 +87,26 @@ def place_row(
def place_note_off(
controller: ProjectController,
*,
- generator: GeneratorName,
+ channel: ChannelName,
row_index: int,
) -> None:
"""Place an explicit note-off command on a channel row."""
- pattern_index = controller.project.song.order[0][generator]
- controller.set_row(generator, pattern_index, row_index, command=NoteOff())
+ pattern_index = controller.project.song.order[0][channel]
+ controller.set_row(channel, pattern_index, row_index, command=NoteOff())
def place_modifier_row(
controller: ProjectController,
*,
- generator: GeneratorName,
+ channel: ChannelName,
row_index: int,
transpose: int | None = None,
volume: int | None = None,
) -> None:
"""Place a row with only modifiers (no instrument)."""
- pattern_index = controller.project.song.order[0][generator]
+ pattern_index = controller.project.song.order[0][channel]
controller.update_row(
- generator,
+ channel,
pattern_index,
row_index,
transpose=transpose,
diff --git a/tests/unit/sampletones_application/logic/sequencer/playback/test_song_length.py b/tests/unit/sampletones_application/logic/sequencer/playback/test_song_length.py
index ab23991af..2cc9aaff0 100644
--- a/tests/unit/sampletones_application/logic/sequencer/playback/test_song_length.py
+++ b/tests/unit/sampletones_application/logic/sequencer/playback/test_song_length.py
@@ -2,7 +2,7 @@
from sampletones_application.logic.project.controller import ProjectController
from sampletones_application.logic.sequencer.playback.synthesizer import SongLength
-from sampletones_application.logic.sequencer.playback.synthesizer.timing import SongTiming
+from sampletones_core.timing import SongTiming
FRACTIONAL_RATE: Final[int] = 22050
EXACT_RATE: Final[int] = 44100
diff --git a/tests/unit/sampletones_application/logic/sequencer/playback/test_synthesizer.py b/tests/unit/sampletones_application/logic/sequencer/playback/test_synthesizer.py
index dbb76e04e..bd980f975 100644
--- a/tests/unit/sampletones_application/logic/sequencer/playback/test_synthesizer.py
+++ b/tests/unit/sampletones_application/logic/sequencer/playback/test_synthesizer.py
@@ -4,25 +4,34 @@
import numpy as np
import pytest
-from sampletones_application.constants.playback import (
- MAX_TICKS_PER_ROW,
- MIN_TICKS_PER_ROW,
-)
from sampletones_application.logic.project.controller import ProjectController
from sampletones_application.logic.sequencer.channels import ALL_CHANNELS
from sampletones_application.logic.sequencer.playback.synthesizer import RowSynthesizer
from sampletones_core.configs import Config
from sampletones_core.constants.audio import DEFAULT_SAMPLE_RATE
-from sampletones_core.constants.enums import FeatureKey, GeneratorName
+from sampletones_core.constants.enums import ChannelName, FeatureKey
from sampletones_core.constants.general import MAX_VOLUME
from sampletones_core.features import CHANNEL_FEATURE_DEFAULTS
+from sampletones_core.performance import ChannelPerformance
from sampletones_core.reconstructions import Reconstruction
-from sampletones_core.timing import Metre, RowRate, calculate_groove
+from sampletones_core.timing import (
+ MAX_TICKS_PER_ROW,
+ MIN_TICKS_PER_ROW,
+ Meter,
+ RowRate,
+ calculate_groove,
+)
+from tests.suite.performance import (
+ make_noise_reconstruction,
+ make_pulse_reconstruction,
+ make_triangle_reconstruction,
+)
from tests.suite.scenario import BaseTestScenario, ScenarioStep
from tests.unit.sampletones_application.logic.sequencer.playback.conftest import (
+ SOUNDING_FRAMES,
+ add_instrument,
add_sample,
make_controller,
- make_pulse_reconstruction,
make_synthesizer,
place_modifier_row,
place_note_off,
@@ -40,12 +49,12 @@ class MaskProvider:
__test__ = False
def __init__(self) -> None:
- self.active: FrozenSet[GeneratorName] = ALL_CHANNELS
+ self.active: FrozenSet[ChannelName] = ALL_CHANNELS
- def mute(self, generator: GeneratorName) -> None:
- self.active = ALL_CHANNELS - {generator}
+ def mute(self, channel: ChannelName) -> None:
+ self.active = ALL_CHANNELS - {channel}
- def __call__(self) -> FrozenSet[GeneratorName]:
+ def __call__(self) -> FrozenSet[ChannelName]:
return self.active
@@ -56,7 +65,7 @@ class SynthesizerContext:
mask: MaskProvider
chunks: List[np.ndarray] = field(default_factory=list)
tick_snapshots: Dict[str, int] = field(default_factory=dict)
- sample_id_snapshots: Dict[str, Optional[str]] = field(default_factory=dict)
+ voice_id_snapshots: Dict[str, Optional[str]] = field(default_factory=dict)
def _make_context() -> SynthesizerContext:
@@ -73,11 +82,11 @@ def _controller(context: SynthesizerContext) -> ProjectController:
return context.controller
-def _state(
+def _performance(
context: SynthesizerContext,
- generator: GeneratorName = GeneratorName.PULSE1,
-):
- return context.synthesizer._channels.state(generator)
+ channel: ChannelName = ChannelName.PULSE1,
+) -> ChannelPerformance:
+ return context.synthesizer._channels.state(channel).performance
def _render(context: SynthesizerContext) -> np.ndarray:
@@ -91,7 +100,7 @@ def _groove_ticks(controller: ProjectController) -> Tuple[int, ...]:
settings = controller.project.settings
return calculate_groove(
RowRate.from_settings(settings),
- Metre.from_settings(settings, rows=controller.project.song.rows_per_pattern),
+ Meter.from_settings(settings, rows=controller.project.song.rows_per_pattern),
minimum_ticks=MIN_TICKS_PER_ROW,
maximum_ticks=MAX_TICKS_PER_ROW,
).ticks
@@ -114,15 +123,15 @@ def place_pulse_sample_on_row_0(context: SynthesizerContext) -> None:
sample = add_sample(_controller(context), recon)
place_row(
_controller(context),
- generator=GeneratorName.PULSE1,
+ channel=ChannelName.PULSE1,
row_index=0,
- sample_id=sample.id,
+ voice_id=sample.id,
)
def render_row_0_and_assert_defaults(context: SynthesizerContext) -> None:
_render(context)
- assert _state(context).transpose == 0
- assert _state(context).volume == MAX_VOLUME
+ assert _performance(context).transpose == 0
+ assert _performance(context).volume == MAX_VOLUME
BaseTestScenario(
label="trigger sets default transpose and volume",
@@ -145,9 +154,9 @@ def place_pulse_sample_with_modifiers(context: SynthesizerContext) -> None:
sample = add_sample(_controller(context), recon)
place_row(
_controller(context),
- generator=GeneratorName.PULSE1,
+ channel=ChannelName.PULSE1,
row_index=0,
- sample_id=sample.id,
+ voice_id=sample.id,
transpose=5,
volume=8,
)
@@ -156,8 +165,8 @@ def render_row_0_and_assert_explicit_values(
context: SynthesizerContext,
) -> None:
_render(context)
- assert _state(context).transpose == 5
- assert _state(context).volume == 8
+ assert _performance(context).transpose == 5
+ assert _performance(context).volume == 8
BaseTestScenario(
label="trigger with explicit modifiers",
@@ -182,23 +191,23 @@ def place_pulse_sample_on_row_0(context: SynthesizerContext) -> None:
sample = add_sample(_controller(context), recon)
place_row(
_controller(context),
- generator=GeneratorName.PULSE1,
+ channel=ChannelName.PULSE1,
row_index=0,
- sample_id=sample.id,
+ voice_id=sample.id,
)
def render_row_0_and_record_state(context: SynthesizerContext) -> None:
_render(context)
- context.tick_snapshots["after_row_0"] = _state(context).tick_index
- context.sample_id_snapshots["triggered"] = _state(context).sample_id
- assert _state(context).sample_id is not None
+ context.tick_snapshots["after_row_0"] = _performance(context).tick_index
+ context.voice_id_snapshots["triggered"] = _performance(context).voice_id
+ assert _performance(context).voice_id is not None
def render_empty_row_1_and_assert_tick_advanced(
context: SynthesizerContext,
) -> None:
_render(context)
- assert _state(context).tick_index > context.tick_snapshots["after_row_0"]
- assert _state(context).sample_id == context.sample_id_snapshots["triggered"]
+ assert _performance(context).tick_index > context.tick_snapshots["after_row_0"]
+ assert _performance(context).voice_id == context.voice_id_snapshots["triggered"]
BaseTestScenario(
label="sustain — empty row continues previous note",
@@ -227,31 +236,31 @@ def setup(context: SynthesizerContext) -> None:
sample = add_sample(_controller(context), recon)
place_row(
_controller(context),
- generator=GeneratorName.PULSE1,
+ channel=ChannelName.PULSE1,
row_index=0,
- sample_id=sample.id,
+ voice_id=sample.id,
volume=15,
)
place_modifier_row(
_controller(context),
- generator=GeneratorName.PULSE1,
+ channel=ChannelName.PULSE1,
row_index=1,
volume=0,
)
def render_row_0_and_record_state(context: SynthesizerContext) -> None:
_render(context)
- context.tick_snapshots["after_row_0"] = _state(context).tick_index
- context.sample_id_snapshots["after_row_0"] = _state(context).sample_id
- assert _state(context).volume == 15
+ context.tick_snapshots["after_row_0"] = _performance(context).tick_index
+ context.voice_id_snapshots["after_row_0"] = _performance(context).voice_id
+ assert _performance(context).volume == 15
def render_modifier_row_and_assert_volume_changed(
context: SynthesizerContext,
) -> None:
_render(context)
- assert _state(context).volume == 0
- assert _state(context).sample_id == context.sample_id_snapshots["after_row_0"]
- assert _state(context).tick_index > context.tick_snapshots["after_row_0"]
+ assert _performance(context).volume == 0
+ assert _performance(context).voice_id == context.voice_id_snapshots["after_row_0"]
+ assert _performance(context).tick_index > context.tick_snapshots["after_row_0"]
BaseTestScenario(
label="modifier-only row changes volume without retriggering",
@@ -275,29 +284,29 @@ def setup(context: SynthesizerContext) -> None:
sample = add_sample(_controller(context), recon)
place_row(
_controller(context),
- generator=GeneratorName.PULSE1,
+ channel=ChannelName.PULSE1,
row_index=0,
- sample_id=sample.id,
+ voice_id=sample.id,
transpose=0,
)
place_modifier_row(
_controller(context),
- generator=GeneratorName.PULSE1,
+ channel=ChannelName.PULSE1,
row_index=1,
transpose=7,
)
def render_row_0_and_record_sample(context: SynthesizerContext) -> None:
_render(context)
- context.sample_id_snapshots["triggered"] = _state(context).sample_id
- assert _state(context).transpose == 0
+ context.voice_id_snapshots["triggered"] = _performance(context).voice_id
+ assert _performance(context).transpose == 0
def render_modifier_row_and_assert_transpose_changed(
context: SynthesizerContext,
) -> None:
_render(context)
- assert _state(context).transpose == 7
- assert _state(context).sample_id == context.sample_id_snapshots["triggered"]
+ assert _performance(context).transpose == 7
+ assert _performance(context).voice_id == context.voice_id_snapshots["triggered"]
BaseTestScenario(
label="modifier-only row changes transpose without retriggering",
@@ -326,13 +335,13 @@ def place_pulse_sample(context: SynthesizerContext) -> None:
sample = add_sample(_controller(context), recon)
place_row(
_controller(context),
- generator=GeneratorName.PULSE1,
+ channel=ChannelName.PULSE1,
row_index=0,
- sample_id=sample.id,
+ voice_id=sample.id,
)
def mute_pulse1(context: SynthesizerContext) -> None:
- context.mask.mute(GeneratorName.PULSE1)
+ context.mask.mute(ChannelName.PULSE1)
def render_and_compare_against_unmasked(context: SynthesizerContext) -> None:
audio_masked = _render(context)
@@ -363,20 +372,20 @@ def test_mask_change_between_rows_takes_effect_without_restart(self) -> None:
pitch and volume the pattern has reached rather than retriggering.
"""
- def place_looping_pulse_sample(context: SynthesizerContext) -> None:
- recon = make_pulse_reconstruction(count=4)
- sample = add_sample(_controller(context), recon, loop=True)
+ def place_sounding_pulse_sample(context: SynthesizerContext) -> None:
+ recon = make_pulse_reconstruction(count=SOUNDING_FRAMES)
+ sample = add_sample(_controller(context), recon)
place_row(
_controller(context),
- generator=GeneratorName.PULSE1,
+ channel=ChannelName.PULSE1,
row_index=0,
- sample_id=sample.id,
+ voice_id=sample.id,
)
def mute_pulse1_and_render_row_0(context: SynthesizerContext) -> None:
- context.mask.mute(GeneratorName.PULSE1)
+ context.mask.mute(ChannelName.PULSE1)
assert np.allclose(_render(context), 0.0)
- assert _state(context).sample_id is not None
+ assert _performance(context).voice_id is not None
def unmute_pulse1_and_render_row_1(context: SynthesizerContext) -> None:
context.mask.active = ALL_CHANNELS
@@ -387,8 +396,8 @@ def unmute_pulse1_and_render_row_1(context: SynthesizerContext) -> None:
build=_make_context,
steps=[
ScenarioStep(
- label="place looping pulse sample on row 0",
- action=place_looping_pulse_sample,
+ label="place sounding pulse sample on row 0",
+ action=place_sounding_pulse_sample,
),
ScenarioStep(
label="mute PULSE1, render row 0 — silence",
@@ -485,17 +494,17 @@ def render_and_check_returned_position(context: SynthesizerContext) -> None:
class TestNoteOff:
- def test_note_off_cuts_a_sounding_looped_voice(self) -> None:
- def place_looped_sample_then_note_off(context: SynthesizerContext) -> None:
- recon = make_pulse_reconstruction(count=2)
- sample = add_sample(_controller(context), recon, loop=True)
+ def test_note_off_cuts_a_sounding_voice(self) -> None:
+ def place_sounding_sample_then_note_off(context: SynthesizerContext) -> None:
+ recon = make_pulse_reconstruction(count=SOUNDING_FRAMES)
+ sample = add_sample(_controller(context), recon)
place_row(
_controller(context),
- generator=GeneratorName.PULSE1,
+ channel=ChannelName.PULSE1,
row_index=0,
- sample_id=sample.id,
+ voice_id=sample.id,
)
- place_note_off(_controller(context), generator=GeneratorName.PULSE1, row_index=1)
+ place_note_off(_controller(context), channel=ChannelName.PULSE1, row_index=1)
def render_row_0_and_assert_audible(context: SynthesizerContext) -> None:
assert not np.all(_render(context) == 0.0)
@@ -503,15 +512,15 @@ def render_row_0_and_assert_audible(context: SynthesizerContext) -> None:
def render_row_1_and_assert_silenced(context: SynthesizerContext) -> None:
audio = _render(context)
assert np.all(audio == 0.0)
- assert _state(context).sample_id is None
+ assert _performance(context).voice_id is None
BaseTestScenario(
- label="note-off silences a looped voice and clears channel state",
+ label="note-off silences a sounding voice and clears channel state",
build=_make_context,
steps=[
ScenarioStep(
- label="place looped sample on row 0, note-off on row 1",
- action=place_looped_sample_then_note_off,
+ label="place sounding sample on row 0, note-off on row 1",
+ action=place_sounding_sample_then_note_off,
),
ScenarioStep(
label="render row 0 — audible",
@@ -525,16 +534,17 @@ def render_row_1_and_assert_silenced(context: SynthesizerContext) -> None:
).run()
-class TestLoopBehavior:
- def test_loop_true_keeps_playing_after_instruction_list_exhausted(self) -> None:
- def place_two_instruction_loop_sample(context: SynthesizerContext) -> None:
- recon = make_pulse_reconstruction(count=2)
- sample = add_sample(_controller(context), recon, loop=True)
+class TestWhenAVoiceRunsOut:
+ """A recording sounds the frames its conversion found; an instrument goes on past them."""
+
+ def test_an_instrument_keeps_sounding_past_the_frames_it_writes(self) -> None:
+ def place_instrument(context: SynthesizerContext) -> None:
+ instrument = add_instrument(_controller(context))
place_row(
_controller(context),
- generator=GeneratorName.PULSE1,
+ channel=ChannelName.PULSE1,
row_index=0,
- sample_id=sample.id,
+ voice_id=instrument.id,
)
def render_row_0_and_assert_non_silence(context: SynthesizerContext) -> None:
@@ -546,15 +556,15 @@ def render_rows_1_to_3_and_assert_tick_advanced(
) -> None:
for _ in range(3):
_render(context)
- assert _state(context).tick_index > 2
+ assert _performance(context).tick_index > 2
BaseTestScenario(
- label="loop=True wraps instruction index",
+ label="an instrument sounds on past its envelopes",
build=_make_context,
steps=[
ScenarioStep(
- label="place 2-instruction looping sample on row 0",
- action=place_two_instruction_loop_sample,
+ label="place a sustaining instrument on row 0",
+ action=place_instrument,
),
ScenarioStep(
label="render row 0 — has audio",
@@ -567,18 +577,18 @@ def render_rows_1_to_3_and_assert_tick_advanced(
],
).run()
- def test_loop_false_produces_silence_after_instructions_end(self) -> None:
+ def test_a_sample_falls_silent_once_its_frames_run_out(self) -> None:
settings = make_controller().project.settings
frame_length = settings.sample_rate // settings.nes_frequency
- def place_one_instruction_non_loop_sample(context: SynthesizerContext) -> None:
+ def place_one_frame_sample(context: SynthesizerContext) -> None:
recon = make_pulse_reconstruction(count=1)
- sample = add_sample(_controller(context), recon, loop=False)
+ sample = add_sample(_controller(context), recon)
place_row(
_controller(context),
- generator=GeneratorName.PULSE1,
+ channel=ChannelName.PULSE1,
row_index=0,
- sample_id=sample.id,
+ voice_id=sample.id,
)
def render_row_0_and_assert_first_tick_audible_rest_silent(
@@ -591,12 +601,12 @@ def render_row_0_and_assert_first_tick_audible_rest_silent(
assert np.all(remaining == 0.0), "ticks after instruction exhaustion should be silent"
BaseTestScenario(
- label="loop=False silences after instruction list exhausted",
+ label="a sample rests once its frames run out",
build=_make_context,
steps=[
ScenarioStep(
- label="place 1-instruction non-looping sample",
- action=place_one_instruction_non_loop_sample,
+ label="place a one-frame sample",
+ action=place_one_frame_sample,
),
ScenarioStep(
label="render row 0 — first tick audible, rest silent",
@@ -605,16 +615,15 @@ def render_row_0_and_assert_first_tick_audible_rest_silent(
],
).run()
- def test_looped_voice_sustains_across_an_empty_next_frame(self) -> None:
- def place_loop_then_append_empty_frame(context: SynthesizerContext) -> None:
+ def test_a_sustaining_voice_carries_into_an_empty_next_frame(self) -> None:
+ def place_instrument_then_append_empty_frame(context: SynthesizerContext) -> None:
controller = _controller(context)
- recon = make_pulse_reconstruction(count=2)
- sample = add_sample(controller, recon, loop=True)
+ instrument = add_instrument(controller)
place_row(
controller,
- generator=GeneratorName.PULSE1,
+ channel=ChannelName.PULSE1,
row_index=0,
- sample_id=sample.id,
+ voice_id=instrument.id,
)
controller.append_frame()
@@ -628,15 +637,15 @@ def render_into_empty_second_frame_and_assert_sustained(
audio, (order_position, _) = context.synthesizer.render_row()
assert order_position == 1
assert not np.all(audio == 0.0)
- assert _state(context).sample_id is not None
+ assert _performance(context).voice_id is not None
BaseTestScenario(
- label="looped voice carries across an empty (None-slot) next frame",
+ label="a sustaining voice carries across an empty (None-slot) next frame",
build=_make_context,
steps=[
ScenarioStep(
- label="loop on frame 0, append all-None frame 1",
- action=place_loop_then_append_empty_frame,
+ label="instrument on frame 0, append all-None frame 1",
+ action=place_instrument_then_append_empty_frame,
),
ScenarioStep(
label="render into frame 1 — voice still sounding",
@@ -650,8 +659,8 @@ class TestSilenceCases:
def test_none_order_slot_with_no_sounding_voice_produces_silence(self) -> None:
def clear_all_order_slots(context: SynthesizerContext) -> None:
song = _controller(context).project.song
- for generator_name in GeneratorName.items():
- song.set_order_entry(0, generator_name, None)
+ for channel_name in ChannelName.items():
+ song.set_order_entry(0, channel_name, None)
def render_and_assert_silence(context: SynthesizerContext) -> None:
audio = _render(context)
@@ -716,7 +725,7 @@ def render_and_assert_chunk_length(context: SynthesizerContext) -> None:
class TestGroove:
- def test_a_pattern_plays_the_groove_the_metre_yields(
+ def test_a_pattern_plays_the_groove_the_meter_yields(
self,
controller: ProjectController,
synthesizer: RowSynthesizer,
@@ -840,19 +849,19 @@ def test_frequency_change_between_rows_takes_effect_without_restart(self) -> Non
controller = make_controller()
recon = make_pulse_reconstruction(count=12)
sample = add_sample(controller, recon)
- place_row(controller, generator=GeneratorName.PULSE1, row_index=0, sample_id=sample.id)
+ place_row(controller, channel=ChannelName.PULSE1, row_index=0, voice_id=sample.id)
synthesizer = make_synthesizer(controller, Config(), sample_rate=SAMPLE_RATE)
controller.set_nes_frequency(60)
synthesizer.render_row()
- pulse_state = synthesizer._channels.state(GeneratorName.PULSE1)
+ pulse_state = synthesizer._channels.state(ChannelName.PULSE1)
assert pulse_state.generator.frame_length == round(SAMPLE_RATE / 60)
controller.set_nes_frequency(30)
synthesizer.render_row()
assert pulse_state.generator.frame_length == round(SAMPLE_RATE / 30)
- assert pulse_state.sample_id is not None
+ assert pulse_state.performance.voice_id is not None
class TestChannelHeldValues:
@@ -874,9 +883,9 @@ def _place(
sample = add_sample(_controller(context), reconstruction, name=name)
place_row(
_controller(context),
- generator=GeneratorName.PULSE1,
+ channel=ChannelName.PULSE1,
row_index=row_index,
- sample_id=sample.id,
+ voice_id=sample.id,
)
@staticmethod
@@ -894,7 +903,7 @@ def test_the_channel_takes_up_the_level_its_instrument_writes(self) -> None:
_render(context)
- assert _state(context).feature_values[FeatureKey.VOLUME] == QUIET_VOLUME
+ assert _performance(context).feature_values[FeatureKey.VOLUME] == QUIET_VOLUME
def test_a_sample_holding_its_level_sounds_at_the_channels(self) -> None:
context = _make_context()
@@ -954,4 +963,4 @@ def test_a_reset_returns_every_channel_to_the_values_a_song_starts_on(self) -> N
context.synthesizer.reset()
- assert _state(context).feature_values == CHANNEL_FEATURE_DEFAULTS
+ assert _performance(context).feature_values == CHANNEL_FEATURE_DEFAULTS
diff --git a/tests/unit/sampletones_application/logic/sequencer/playback/test_tick_clock.py b/tests/unit/sampletones_application/logic/sequencer/playback/test_tick_clock.py
index 7d18bc324..6308e4726 100644
--- a/tests/unit/sampletones_application/logic/sequencer/playback/test_tick_clock.py
+++ b/tests/unit/sampletones_application/logic/sequencer/playback/test_tick_clock.py
@@ -4,18 +4,18 @@
import numpy as np
import pytest
-from sampletones_application.constants.playback import MAX_TICKS_PER_ROW, MIN_TICKS_PER_ROW
from sampletones_application.logic.project.controller import ProjectController
from sampletones_application.logic.sequencer.playback.synthesizer import RowSynthesizer
from sampletones_core.configs import Config
-from sampletones_core.constants.enums import GeneratorName
-from sampletones_core.timing import Metre, RowRate, TickClock, calculate_groove
+from sampletones_core.constants.enums import ChannelName
+from sampletones_core.timing import MAX_TICKS_PER_ROW, MIN_TICKS_PER_ROW, Meter, RowRate, TickClock, calculate_groove
from tests.suite.base import BaseTestSuite
+from tests.suite.performance import make_pulse_reconstruction
from tests.unit.sampletones_application.logic.sequencer.playback.conftest import (
+ SOUNDING_FRAMES,
add_sample,
all_channels,
make_controller,
- make_pulse_reconstruction,
make_synthesizer,
place_row,
)
@@ -29,7 +29,7 @@ def _expected_ticks(controller: ProjectController) -> Tuple[int, ...]:
settings = controller.project.settings
return calculate_groove(
RowRate.from_settings(settings),
- Metre.from_settings(settings, rows=controller.project.song.rows_per_pattern),
+ Meter.from_settings(settings, rows=controller.project.song.rows_per_pattern),
minimum_ticks=MIN_TICKS_PER_ROW,
maximum_ticks=MAX_TICKS_PER_ROW,
).ticks
@@ -223,9 +223,9 @@ class TestChannelsFillTheRow(BaseTestSuite):
def test_a_sounding_channel_fills_every_tick(self) -> None:
controller = make_controller()
- reconstruction = make_pulse_reconstruction(count=1)
- sample = add_sample(controller, reconstruction, loop=True)
- place_row(controller, generator=GeneratorName.PULSE1, row_index=0, sample_id=sample.id)
+ reconstruction = make_pulse_reconstruction(count=SOUNDING_FRAMES)
+ sample = add_sample(controller, reconstruction)
+ place_row(controller, channel=ChannelName.PULSE1, row_index=0, voice_id=sample.id)
synthesizer = make_synthesizer(controller, Config(), sample_rate=UNEVEN_SAMPLE_RATE)
chunk, _ = synthesizer.render_row()
@@ -236,9 +236,9 @@ def test_a_sounding_channel_fills_every_tick(self) -> None:
def test_a_sounding_note_stays_continuous_across_a_tick_length_change(self) -> None:
"""A tick of a different length resumes the oscillator where the last one ended."""
controller = make_controller()
- reconstruction = make_pulse_reconstruction(count=1)
- sample = add_sample(controller, reconstruction, loop=True)
- place_row(controller, generator=GeneratorName.PULSE1, row_index=0, sample_id=sample.id)
+ reconstruction = make_pulse_reconstruction(count=SOUNDING_FRAMES)
+ sample = add_sample(controller, reconstruction)
+ place_row(controller, channel=ChannelName.PULSE1, row_index=0, voice_id=sample.id)
synthesizer = make_synthesizer(controller, Config(), sample_rate=UNEVEN_SAMPLE_RATE)
chunk, _ = synthesizer.render_row()
diff --git a/tests/unit/sampletones_application/logic/sequencer/test_channels.py b/tests/unit/sampletones_application/logic/sequencer/test_channels.py
index 965284fac..2863754d8 100644
--- a/tests/unit/sampletones_application/logic/sequencer/test_channels.py
+++ b/tests/unit/sampletones_application/logic/sequencer/test_channels.py
@@ -10,23 +10,23 @@
from sampletones_application.view_model.sequencer.channels import (
SequencerChannelsViewModel,
)
-from sampletones_core.constants.enums import GeneratorName
+from sampletones_core.constants.enums import ChannelName
from tests.suite.case import BaseTestCase
Gesture = Callable[[SequencerChannelsLogic], None]
-PULSE1 = GeneratorName.PULSE1
-PULSE2 = GeneratorName.PULSE2
-TRIANGLE = GeneratorName.TRIANGLE
-NOISE = GeneratorName.NOISE
+PULSE1 = ChannelName.PULSE1
+PULSE2 = ChannelName.PULSE2
+TRIANGLE = ChannelName.TRIANGLE
+NOISE = ChannelName.NOISE
-def toggle(generator: GeneratorName) -> Gesture:
- return lambda logic: logic.toggle(generator)
+def toggle(channel: ChannelName) -> Gesture:
+ return lambda logic: logic.toggle(channel)
-def solo(generator: GeneratorName) -> Gesture:
- return lambda logic: logic.solo(generator)
+def solo(channel: ChannelName) -> Gesture:
+ return lambda logic: logic.solo(channel)
def toggle_all() -> Gesture:
@@ -49,7 +49,7 @@ def reset() -> Gesture:
class GestureCase(BaseTestCase):
label: str
gestures: Tuple[Gesture, ...]
- expected_muted: FrozenSet[GeneratorName]
+ expected_muted: FrozenSet[ChannelName]
GESTURE_CASES = [
diff --git a/tests/unit/sampletones_application/logic/sequencer/test_history_detail.py b/tests/unit/sampletones_application/logic/sequencer/test_history_detail.py
index 1baec8650..7f65fa2fe 100644
--- a/tests/unit/sampletones_application/logic/sequencer/test_history_detail.py
+++ b/tests/unit/sampletones_application/logic/sequencer/test_history_detail.py
@@ -9,8 +9,8 @@
from sampletones_application.logic.sequencer.history_detail import (
SequencerHistoryDetail,
)
-from sampletones_application.logic.sequencer.samples import SequencerSamplesLogic
from sampletones_application.logic.sequencer.tracker import SequencerTrackerLogic
+from sampletones_application.logic.sequencer.voices import SequencerVoicesLogic
from sampletones_application.view_model.sequencer.region import (
OrderCell,
OrderRegion,
@@ -22,11 +22,11 @@
from sampletones_application.view_model.shared.history import (
HistoryDetailRole,
HistoryDetailSegment,
- HistoryDetailWord,
- HistoryDetailWordSegment,
)
-from sampletones_core.constants.enums import FeatureKey, GeneratorName
-from tests.suite.sequencer import sample_reconstruction
+from sampletones_core.constants.enums import ChannelName, FeatureKey
+from sampletones_core.project.voices.creation import new_instrument
+from sampletones_core.utils.display import display_id
+from tests.suite.sequencer import UNKNOWN_SAMPLE_ID, sample_reconstruction
Pair = Tuple[str, HistoryDetailRole]
@@ -37,13 +37,13 @@ def _controller() -> ProjectController:
def _formatter(controller: ProjectController) -> SequencerHistoryDetail:
tracker_logic = SequencerTrackerLogic(controller)
- samples_logic = SequencerSamplesLogic(
+ voices_logic = SequencerVoicesLogic(
controller,
MagicMock(),
MagicMock(),
scheduling=MagicMock(),
)
- return SequencerHistoryDetail(tracker_logic, samples_logic)
+ return SequencerHistoryDetail(tracker_logic, voices_logic)
def _pairs(segments: Tuple[HistoryDetailSegment, ...]) -> List[Pair]:
@@ -53,11 +53,11 @@ def _pairs(segments: Tuple[HistoryDetailSegment, ...]) -> List[Pair]:
class TestTrackerDetails:
def test_edit_row_single_channel_places_sample(self) -> None:
controller = _controller()
- controller.add_sample(sample_reconstruction([GeneratorName.PULSE1]), name="lead")
- target = controller.add_sample(sample_reconstruction([GeneratorName.PULSE1]), name="bass")
+ controller.add_sample(sample_reconstruction([ChannelName.PULSE1]), name="lead")
+ target = controller.add_sample(sample_reconstruction([ChannelName.PULSE1]), name="bass")
formatter = _formatter(controller)
- segments = formatter.edit_row(10, GeneratorName.PULSE1, target.id, None, None)
+ segments = formatter.edit_row(10, ChannelName.PULSE1, target.id, None, None)
assert _pairs(segments) == [
("00", HistoryDetailRole.FRAME),
@@ -70,7 +70,7 @@ def test_edit_row_single_channel_places_sample(self) -> None:
def test_edit_row_sample_column_lists_the_samples_channels(self) -> None:
controller = _controller()
sample = controller.add_sample(
- sample_reconstruction([GeneratorName.PULSE1, GeneratorName.TRIANGLE, GeneratorName.NOISE]),
+ sample_reconstruction([ChannelName.PULSE1, ChannelName.TRIANGLE, ChannelName.NOISE]),
name="chord",
)
formatter = _formatter(controller)
@@ -89,7 +89,7 @@ def test_edit_row_transpose_shows_subcolumn_and_value(self) -> None:
controller = _controller()
formatter = _formatter(controller)
- segments = formatter.edit_row(0, GeneratorName.TRIANGLE, None, 5, None)
+ segments = formatter.edit_row(0, ChannelName.TRIANGLE, None, 5, None)
assert _pairs(segments) == [
("00", HistoryDetailRole.FRAME),
@@ -115,7 +115,7 @@ def test_clear_subcolumn_names_the_column(self) -> None:
controller = _controller()
formatter = _formatter(controller)
- segments = formatter.clear_subcolumn(0, GeneratorName.NOISE, SubColumn.VOLUME)
+ segments = formatter.clear_subcolumn(0, ChannelName.NOISE, SubColumn.VOLUME)
assert _pairs(segments) == [
("00", HistoryDetailRole.FRAME),
@@ -131,8 +131,8 @@ def test_a_block_reads_as_the_channels_and_the_rows_it_covers(self) -> None:
TrackerRegion(
first_row=4,
last_row=11,
- first_slot=TrackerSlot(GeneratorName.PULSE1, SubColumn.TRANSPOSE).flat_index,
- last_slot=TrackerSlot(GeneratorName.PULSE2, SubColumn.INSTRUMENT).flat_index,
+ first_slot=TrackerSlot(ChannelName.PULSE1, SubColumn.TRANSPOSE).flat_index,
+ last_slot=TrackerSlot(ChannelName.PULSE2, SubColumn.VOICE).flat_index,
)
)
@@ -149,7 +149,7 @@ def test_a_block_reaching_the_sample_column_reads_as_every_channel(self) -> None
TrackerRegion(
first_row=0,
last_row=0,
- first_slot=TrackerSlot(None, SubColumn.INSTRUMENT).flat_index,
+ first_slot=TrackerSlot(None, SubColumn.VOICE).flat_index,
last_slot=TrackerSlot(None, SubColumn.VOLUME).flat_index,
)
)
@@ -163,7 +163,7 @@ def test_a_block_reaching_the_sample_column_reads_as_every_channel(self) -> None
def test_a_paste_reads_as_the_cell_it_was_written_from(self) -> None:
formatter = _formatter(_controller())
- segments = formatter.tracker_paste(TrackerCell(row=3, generator=GeneratorName.NOISE))
+ segments = formatter.tracker_paste(TrackerCell(row=3, channel=ChannelName.NOISE))
assert _pairs(segments) == [
("00", HistoryDetailRole.FRAME),
@@ -179,8 +179,8 @@ def test_adjust_transpose_shows_signed_delta(self) -> None:
TrackerRegion(
first_row=0,
last_row=0,
- first_slot=TrackerSlot(GeneratorName.PULSE2, SubColumn.INSTRUMENT).flat_index,
- last_slot=TrackerSlot(GeneratorName.PULSE2, SubColumn.VOLUME).flat_index,
+ first_slot=TrackerSlot(ChannelName.PULSE2, SubColumn.VOICE).flat_index,
+ last_slot=TrackerSlot(ChannelName.PULSE2, SubColumn.VOLUME).flat_index,
),
-3,
)
@@ -201,8 +201,8 @@ def test_adjust_volume_reads_the_rows_it_covers(self) -> None:
TrackerRegion(
first_row=0,
last_row=3,
- first_slot=TrackerSlot(GeneratorName.PULSE1, SubColumn.VOLUME).flat_index,
- last_slot=TrackerSlot(GeneratorName.PULSE2, SubColumn.VOLUME).flat_index,
+ first_slot=TrackerSlot(ChannelName.PULSE1, SubColumn.VOLUME).flat_index,
+ last_slot=TrackerSlot(ChannelName.PULSE2, SubColumn.VOLUME).flat_index,
),
-1,
)
@@ -243,7 +243,7 @@ def test_move_frame_shows_source_and_destination(self) -> None:
def test_set_order_entry_maps_channel_to_pattern(self) -> None:
formatter = _formatter(_controller())
- assert _pairs(formatter.set_order_entry(GeneratorName.PULSE1, 1, 5)) == [
+ assert _pairs(formatter.set_order_entry(ChannelName.PULSE1, 1, 5)) == [
("01", HistoryDetailRole.FRAME),
("P", HistoryDetailRole.CHANNEL),
(">", HistoryDetailRole.SEPARATOR),
@@ -265,8 +265,8 @@ def test_a_block_reads_as_the_positions_and_the_channels_it_covers(self) -> None
segments = formatter.order_block(
OrderRegion(
- first_row=CHANNEL_AXIS.index(GeneratorName.PULSE2),
- last_row=CHANNEL_AXIS.index(GeneratorName.TRIANGLE),
+ first_row=CHANNEL_AXIS.index(ChannelName.PULSE2),
+ last_row=CHANNEL_AXIS.index(ChannelName.TRIANGLE),
first_position=1,
last_position=4,
)
@@ -297,7 +297,7 @@ def test_a_block_reaching_the_master_row_reads_as_every_channel(self) -> None:
def test_a_paste_reads_as_the_cell_it_was_written_from(self) -> None:
formatter = _formatter(_controller())
- segments = formatter.order_paste(OrderCell(generator=GeneratorName.NOISE, position=3))
+ segments = formatter.order_paste(OrderCell(channel=ChannelName.NOISE, position=3))
assert _pairs(segments) == [
("03", HistoryDetailRole.FRAME),
@@ -305,85 +305,137 @@ def test_a_paste_reads_as_the_cell_it_was_written_from(self) -> None:
]
-class TestSampleDetails:
+class TestVoiceDetails:
def test_add_sample_shows_the_name(self) -> None:
formatter = _formatter(_controller())
- assert _pairs(formatter.add_sample("Bass")) == [("Bass", HistoryDetailRole.NAME)]
+ assert _pairs(formatter.add_sample("Bass")) == [("Bass", HistoryDetailRole.SAMPLE)]
def test_remove_sample_shows_position_and_name(self) -> None:
controller = _controller()
- sample = controller.add_sample(sample_reconstruction([GeneratorName.PULSE1]), name="Bass")
+ sample = controller.add_sample(sample_reconstruction([ChannelName.PULSE1]), name="Bass")
formatter = _formatter(controller)
- assert _pairs(formatter.remove_sample(sample.id)) == [
+ assert _pairs(formatter.remove_voice(sample.id)) == [
("00:", HistoryDetailRole.SAMPLE),
- ("Bass", HistoryDetailRole.NAME),
+ ("Bass", HistoryDetailRole.SAMPLE),
]
def test_replace_sample_shows_position_and_both_names(self) -> None:
controller = _controller()
- sample = controller.add_sample(sample_reconstruction([GeneratorName.PULSE1]), name="Bass")
+ sample = controller.add_sample(sample_reconstruction([ChannelName.PULSE1]), name="Bass")
formatter = _formatter(controller)
assert _pairs(formatter.replace_sample(sample.id, "Kick")) == [
("00:", HistoryDetailRole.SAMPLE),
- ("Bass", HistoryDetailRole.NAME),
+ ("Bass", HistoryDetailRole.SAMPLE),
(">", HistoryDetailRole.SEPARATOR),
- ("Kick", HistoryDetailRole.NAME),
+ ("Kick", HistoryDetailRole.SAMPLE),
]
def test_rename_sample_shows_old_and_new(self) -> None:
- formatter = _formatter(_controller())
+ controller = _controller()
+ sample = controller.add_sample(sample_reconstruction([ChannelName.PULSE1]), name="Bass")
+ formatter = _formatter(controller)
- assert _pairs(formatter.rename_sample("Bass", "Kick")) == [
- ("Bass", HistoryDetailRole.NAME),
+ assert _pairs(formatter.rename_voice(sample.id, "Kick")) == [
+ ("Bass", HistoryDetailRole.SAMPLE),
(">", HistoryDetailRole.SEPARATOR),
- ("Kick", HistoryDetailRole.NAME),
+ ("Kick", HistoryDetailRole.SAMPLE),
]
def test_move_sample_shows_source_position_and_destination(self) -> None:
controller = _controller()
- sample = controller.add_sample(sample_reconstruction([GeneratorName.PULSE1]), name="Bass")
+ sample = controller.add_sample(sample_reconstruction([ChannelName.PULSE1]), name="Bass")
formatter = _formatter(controller)
- assert _pairs(formatter.move_sample(sample.id, 5)) == [
+ assert _pairs(formatter.move_voice(sample.id, 5)) == [
("00", HistoryDetailRole.SAMPLE),
(">", HistoryDetailRole.SEPARATOR),
("05", HistoryDetailRole.VALUE),
]
- def test_set_sample_loop_stores_the_state_as_a_word_key(self) -> None:
+ def test_value_wraps_a_number(self) -> None:
+ formatter = _formatter(_controller())
+
+ assert _pairs(formatter.value(150)) == [("150", HistoryDetailRole.VALUE)]
+
+
+class TestWhichKindADetailNames:
+ """A line about the pool reads in the color of the kind of voice it is about."""
+
+ def test_a_written_voice_is_added_under_its_own_kind(self) -> None:
+ formatter = _formatter(_controller())
+
+ assert _pairs(formatter.add_instrument("Pad")) == [("Pad", HistoryDetailRole.INSTRUMENT)]
+
+ def test_a_written_voice_is_removed_under_its_own_kind(self) -> None:
controller = _controller()
- sample = controller.add_sample(sample_reconstruction([GeneratorName.PULSE1]), name="Bass")
+ controller.add_sample(sample_reconstruction([ChannelName.PULSE1]), name="Bass")
+ instrument = controller.add_instrument(new_instrument("Pad"))
formatter = _formatter(controller)
- on_segments = formatter.set_sample_loop(sample.id, True)
- off_segments = formatter.set_sample_loop(sample.id, False)
+ assert _pairs(formatter.remove_voice(instrument.id)) == [
+ ("01:", HistoryDetailRole.INSTRUMENT),
+ ("Pad", HistoryDetailRole.INSTRUMENT),
+ ]
- assert on_segments[0] == HistoryDetailSegment(text="00:", role=HistoryDetailRole.SAMPLE)
- assert on_segments[1] == HistoryDetailWordSegment(
- word=HistoryDetailWord.LOOP_ON,
- role=HistoryDetailRole.VALUE,
- )
- assert off_segments[1] == HistoryDetailWordSegment(
- word=HistoryDetailWord.LOOP_OFF,
- role=HistoryDetailRole.VALUE,
- )
+ def test_a_written_voice_is_renamed_under_its_own_kind(self) -> None:
+ controller = _controller()
+ instrument = controller.add_instrument(new_instrument("Pad"))
+ formatter = _formatter(controller)
- def test_value_wraps_a_number(self) -> None:
+ assert _pairs(formatter.rename_voice(instrument.id, "Strings")) == [
+ ("Pad", HistoryDetailRole.INSTRUMENT),
+ (">", HistoryDetailRole.SEPARATOR),
+ ("Strings", HistoryDetailRole.INSTRUMENT),
+ ]
+
+ def test_a_written_voice_is_duplicated_under_its_own_kind(self) -> None:
+ controller = _controller()
+ instrument = controller.add_instrument(new_instrument("Pad"))
+ formatter = _formatter(controller)
+
+ assert _pairs(formatter.duplicate_voice(instrument.id)) == [
+ ("00:", HistoryDetailRole.INSTRUMENT),
+ ("Pad", HistoryDetailRole.INSTRUMENT),
+ ]
+
+ def test_the_kinds_read_apart_where_one_gesture_serves_both(self) -> None:
+ """Moving is one gesture over the whole pool, so its line says which kind moved."""
+ controller = _controller()
+ sample = controller.add_sample(sample_reconstruction([ChannelName.PULSE1]), name="Bass")
+ instrument = controller.add_instrument(new_instrument("Pad"))
+ formatter = _formatter(controller)
+
+ assert formatter.move_voice(sample.id, 1)[0].role is HistoryDetailRole.SAMPLE
+ assert formatter.move_voice(instrument.id, 0)[0].role is HistoryDetailRole.INSTRUMENT
+
+ def test_a_placed_voice_names_its_kind_in_the_tracker(self) -> None:
+ controller = _controller()
+ instrument = controller.add_instrument(new_instrument("Pad"))
+ formatter = _formatter(controller)
+
+ segments = formatter.edit_row(0, ChannelName.PULSE2, instrument.id, None, None)
+
+ assert _pairs(segments)[-1] == ("00", HistoryDetailRole.INSTRUMENT)
+
+ def test_a_voice_the_pool_no_longer_holds_keeps_the_plain_role(self) -> None:
+ """An id nothing answers for states no kind, so it reads as the voice slot itself does."""
formatter = _formatter(_controller())
- assert _pairs(formatter.value(150)) == [("150", HistoryDetailRole.VALUE)]
+ segments = formatter.edit_row(0, ChannelName.PULSE1, UNKNOWN_SAMPLE_ID, None, None)
+
+ assert _pairs(segments)[-1] == (display_id(None), HistoryDetailRole.VOICE)
class TestReconstructionDetails:
def test_edit_reconstruction_names_position_channel_and_feature(self) -> None:
controller = _controller()
- sample = controller.add_sample(sample_reconstruction([GeneratorName.PULSE1]), name="lead")
+ sample = controller.add_sample(sample_reconstruction([ChannelName.PULSE1]), name="lead")
formatter = _formatter(controller)
- segments = formatter.edit_reconstruction(sample.id, GeneratorName.PULSE1, FeatureKey.VOLUME)
+ segments = formatter.edit_reconstruction(sample.id, ChannelName.PULSE1, FeatureKey.VOLUME)
assert _pairs(segments) == [
("00:", HistoryDetailRole.SAMPLE),
@@ -391,6 +443,44 @@ def test_edit_reconstruction_names_position_channel_and_feature(self) -> None:
("v", HistoryDetailRole.FEATURE_VOLUME),
]
+ def test_edit_instrument_names_position_and_feature(self) -> None:
+ """An instrument is one set every channel reads, so the line names no channel."""
+ controller = _controller()
+ instrument = controller.add_instrument(new_instrument("Pad"))
+ formatter = _formatter(controller)
+
+ segments = formatter.edit_instrument(instrument.id, FeatureKey.VOLUME)
+
+ assert _pairs(segments) == [
+ ("00:", HistoryDetailRole.INSTRUMENT),
+ ("v", HistoryDetailRole.FEATURE_VOLUME),
+ ]
+
+ @pytest.mark.parametrize(
+ ("feature_key", "letter", "role"),
+ [
+ (FeatureKey.INITIAL_PITCH, "i", HistoryDetailRole.FEATURE_PITCH),
+ (FeatureKey.VOLUME, "v", HistoryDetailRole.FEATURE_VOLUME),
+ (FeatureKey.ARPEGGIO, "a", HistoryDetailRole.FEATURE_ARPEGGIO),
+ (FeatureKey.PITCH, "p", HistoryDetailRole.FEATURE_PITCH),
+ (FeatureKey.HI_PITCH, "h", HistoryDetailRole.FEATURE_PITCH),
+ (FeatureKey.DUTY_CYCLE, "d", HistoryDetailRole.FEATURE_DUTY_CYCLE),
+ ],
+ )
+ def test_an_instrument_edit_names_its_dimension_by_the_same_letter(
+ self,
+ feature_key: FeatureKey,
+ letter: str,
+ role: HistoryDetailRole,
+ ) -> None:
+ controller = _controller()
+ instrument = controller.add_instrument(new_instrument("Pad"))
+ formatter = _formatter(controller)
+
+ segments = formatter.edit_instrument(instrument.id, feature_key)
+
+ assert (segments[-1].text, segments[-1].role) == (letter, role)
+
@pytest.mark.parametrize(
("feature_key", "letter", "role"),
[
@@ -402,16 +492,16 @@ def test_edit_reconstruction_names_position_channel_and_feature(self) -> None:
(FeatureKey.DUTY_CYCLE, "d", HistoryDetailRole.FEATURE_DUTY_CYCLE),
],
)
- def test_every_feature_has_a_letter_and_a_colour_role(
+ def test_every_feature_has_a_letter_and_a_color_role(
self,
feature_key: FeatureKey,
letter: str,
role: HistoryDetailRole,
) -> None:
controller = _controller()
- sample = controller.add_sample(sample_reconstruction([GeneratorName.PULSE1]), name="lead")
+ sample = controller.add_sample(sample_reconstruction([ChannelName.PULSE1]), name="lead")
formatter = _formatter(controller)
- segments = formatter.edit_reconstruction(sample.id, GeneratorName.PULSE1, feature_key)
+ segments = formatter.edit_reconstruction(sample.id, ChannelName.PULSE1, feature_key)
assert (segments[-1].text, segments[-1].role) == (letter, role)
diff --git a/tests/unit/sampletones_application/logic/sequencer/test_samples.py b/tests/unit/sampletones_application/logic/sequencer/test_samples.py
deleted file mode 100644
index f0a23ecad..000000000
--- a/tests/unit/sampletones_application/logic/sequencer/test_samples.py
+++ /dev/null
@@ -1,309 +0,0 @@
-from typing import Callable, Tuple
-from unittest.mock import MagicMock
-
-import numpy as np
-
-from sampletones_application.logic.project.controller import ProjectController
-from sampletones_application.logic.project.manager import ProjectManager
-from sampletones_application.logic.sequencer.samples import SequencerSamplesLogic
-from sampletones_application.logic.shared.playback_priority import PlaybackPriority
-from sampletones_application.view_model.shared.footprint import SampleFootprintViewModel
-from sampletones_core.constants.enums import GeneratorName
-from sampletones_core.formats.famitracker.footprint import reconstruction_footprints
-from sampletones_core.project.instruments.instrument import Instrument
-from sampletones_core.reconstructions import Reconstruction
-from tests.suite.sequencer import sample_reconstruction
-
-
-def _logic() -> Tuple[ProjectController, SequencerSamplesLogic]:
- controller = ProjectController(ProjectManager())
- logic = SequencerSamplesLogic(
- controller,
- MagicMock(),
- MagicMock(),
- scheduling=MagicMock(),
- )
- return controller, logic
-
-
-def _logic_with_mocks() -> Tuple[
- ProjectController,
- SequencerSamplesLogic,
- MagicMock,
- MagicMock,
-]:
- controller = ProjectController(ProjectManager())
- session_manager = MagicMock()
- audio_device_manager = MagicMock()
- logic = SequencerSamplesLogic(
- controller,
- session_manager,
- audio_device_manager,
- scheduling=MagicMock(),
- )
- return controller, logic, session_manager, audio_device_manager
-
-
-def _place_instrument(
- controller: ProjectController,
- generator: GeneratorName,
- sample_id: str,
-) -> None:
- pattern_index = controller.project.song.order[0][generator]
- controller.set_row(
- generator,
- pattern_index,
- 0,
- command=Instrument(sample_id=sample_id, generator_name=generator),
- )
-
-
-class TestSampleName:
- def test_returns_the_sample_name(
- self,
- reconstruction_factory: Callable[[], Reconstruction],
- ) -> None:
- controller, logic = _logic()
- sample = controller.add_sample(reconstruction_factory(), name="lead")
- assert logic.sample_name(sample.id) == "lead"
-
-
-class TestIsSampleUsed:
- def test_false_for_unreferenced_sample(
- self,
- reconstruction_factory: Callable[[], Reconstruction],
- ) -> None:
- controller, logic = _logic()
- sample = controller.add_sample(reconstruction_factory(), name="lead")
- assert logic.is_sample_used(sample.id) is False
-
- def test_true_after_placing_in_a_pattern(
- self,
- reconstruction_factory: Callable[[], Reconstruction],
- ) -> None:
- controller, logic = _logic()
- sample = controller.add_sample(reconstruction_factory(), name="lead")
- _place_instrument(controller, GeneratorName.PULSE1, sample.id)
- assert logic.is_sample_used(sample.id) is True
-
-
-class TestRemoveSample:
- def test_removes_unused_sample_from_pool(
- self,
- reconstruction_factory: Callable[[], Reconstruction],
- ) -> None:
- controller, logic = _logic()
- sample = controller.add_sample(reconstruction_factory(), name="lead")
-
- logic.remove_sample(sample.id)
-
- assert controller.project.sample(sample.id) is None
-
- def test_removing_used_sample_clears_its_references(
- self, reconstruction_factory: Callable[[], Reconstruction]
- ) -> None:
- controller, logic = _logic()
- sample = controller.add_sample(reconstruction_factory(), name="lead")
- _place_instrument(controller, GeneratorName.PULSE1, sample.id)
-
- logic.remove_sample(sample.id)
-
- assert controller.project.sample(sample.id) is None
- assert logic.is_sample_used(sample.id) is False
-
-
-class TestMoveSample:
- def test_move_sample_reorders_pool(
- self,
- reconstruction_factory: Callable[[], Reconstruction],
- ) -> None:
- controller, logic = _logic()
- first = controller.add_sample(reconstruction_factory(), name="first")
- controller.add_sample(reconstruction_factory(), name="second")
-
- logic.move_sample(first.id, 1)
-
- assert [sample.name for sample in controller.project.samples] == [
- "second",
- "first",
- ]
-
-
-class TestDuplicateSample:
- def test_duplicate_sample_appends_copy(
- self,
- reconstruction_factory: Callable[[], Reconstruction],
- ) -> None:
- controller, logic = _logic()
- source = controller.add_sample(reconstruction_factory(), name="lead")
-
- logic.duplicate_sample(source.id)
-
- assert [sample.name for sample in controller.project.samples] == [
- "lead",
- "lead",
- ]
-
-
-class TestBuildSamples:
- def test_lists_added_samples_in_insertion_order(
- self,
- reconstruction_factory: Callable[[], Reconstruction],
- ) -> None:
- controller, logic = _logic()
- first = controller.add_sample(reconstruction_factory(), name="first")
- second = controller.add_sample(reconstruction_factory(), name="second")
-
- view_model = logic.build_samples()
-
- assert [entry.sample_id for entry in view_model.samples] == [
- first.id,
- second.id,
- ]
- assert [entry.name for entry in view_model.samples] == [
- "first",
- "second",
- ]
-
-
-class TestBuildSampleFootprint:
- """The samples menu prints what a sample occupies, measured the way the sample is placed."""
-
- def test_it_names_each_playing_channel(self) -> None:
- controller, logic = _logic()
- generators = (GeneratorName.PULSE1, GeneratorName.TRIANGLE)
- sample = controller.add_sample(sample_reconstruction(generators), name="bell")
-
- footprint = logic.build_sample_footprint(sample.id)
-
- assert footprint is not None
- assert [instrument.generator for instrument in footprint.instruments] == list(generators)
-
- def test_it_measures_the_sample_under_its_own_loop_flag(
- self,
- reconstruction_factory: Callable[[], Reconstruction],
- ) -> None:
- controller, logic = _logic()
- sample = controller.add_sample(reconstruction_factory(), name="lead")
- controller.set_sample_loop(sample.id, True)
-
- footprint = logic.build_sample_footprint(sample.id)
-
- assert footprint == SampleFootprintViewModel.from_footprints(
- reconstruction_footprints(sample.reconstruction, loop=True)
- )
-
- def test_a_looping_sample_costs_less_than_a_one_shot(
- self,
- reconstruction_factory: Callable[[], Reconstruction],
- ) -> None:
- """A looping instrument shares the shortest dimension's length, so it stores fewer items."""
- controller, logic = _logic()
- sample = controller.add_sample(reconstruction_factory(), name="lead")
- one_shot = logic.build_sample_footprint(sample.id)
-
- controller.set_sample_loop(sample.id, True)
- looping = logic.build_sample_footprint(sample.id)
-
- assert one_shot is not None and looping is not None
- assert looping.total_bytes < one_shot.total_bytes
-
- def test_each_channel_is_measured_as_the_instrument_it_sounds(self) -> None:
- """A channel's figure is the cost of its own instrument, and the channels differ.
-
- The triangle states a pitch alone where the pulse states a level and a waveform too, so
- the same frame written on each costs the triangle the less.
- """
- controller, logic = _logic()
- generators = (GeneratorName.PULSE1, GeneratorName.TRIANGLE)
- sample = controller.add_sample(sample_reconstruction(generators), name="bell")
-
- footprint = logic.build_sample_footprint(sample.id)
-
- assert footprint is not None
- assert footprint.bytes_for(GeneratorName.TRIANGLE) < footprint.bytes_for(GeneratorName.PULSE1)
-
- def test_a_sample_the_pool_has_dropped_is_measured_nowhere(self) -> None:
- _, logic = _logic()
-
- assert logic.build_sample_footprint("missing") is None
-
-
-class TestPlaySample:
- def test_plays_reconstruction_regardless_of_autoplay(
- self, reconstruction_factory: Callable[[], Reconstruction]
- ) -> None:
- controller, logic, session_manager, audio_device_manager = _logic_with_mocks()
- session_manager.autoplay = False
- sample = controller.add_sample(reconstruction_factory(), name="lead")
-
- logic.play_sample(sample.id)
-
- audio_device_manager.play.assert_called_once()
- call = audio_device_manager.play.call_args
- assert np.array_equal(call.args[0], sample.reconstruction.approximation)
- assert call.kwargs["priority"] == PlaybackPriority.NORMAL
- assert call.kwargs["update"] is False
-
- def test_unknown_sample_is_ignored(self) -> None:
- _, logic, _, audio_device_manager = _logic_with_mocks()
-
- logic.play_sample("missing")
-
- audio_device_manager.play.assert_not_called()
-
-
-class TestAutoplay:
- def test_executes_pending_preview_when_autoplay_enabled(
- self, reconstruction_factory: Callable[[], Reconstruction]
- ) -> None:
- controller, logic, session_manager, audio_device_manager = _logic_with_mocks()
- session_manager.autoplay = True
- sample = controller.add_sample(reconstruction_factory(), name="lead")
- logic._pending_autoplay_sample = sample.id
-
- logic._execute_autoplay()
-
- audio_device_manager.play.assert_called_once()
- assert audio_device_manager.play.call_args.kwargs["priority"] == PlaybackPriority.PREVIEW
-
- def test_skips_pending_preview_when_autoplay_disabled(
- self,
- reconstruction_factory: Callable[[], Reconstruction],
- ) -> None:
- controller, logic, session_manager, audio_device_manager = _logic_with_mocks()
- session_manager.autoplay = False
- sample = controller.add_sample(reconstruction_factory(), name="lead")
- logic._pending_autoplay_sample = sample.id
-
- logic._execute_autoplay()
-
- audio_device_manager.play.assert_not_called()
-
- def test_cancel_autoplay_drops_pending_preview(
- self,
- reconstruction_factory: Callable[[], Reconstruction],
- ) -> None:
- controller, logic, session_manager, audio_device_manager = _logic_with_mocks()
- session_manager.autoplay = True
- sample = controller.add_sample(reconstruction_factory(), name="lead")
- logic._pending_autoplay_sample = sample.id
-
- logic.cancel_autoplay()
- logic._execute_autoplay()
-
- audio_device_manager.play.assert_not_called()
-
- def test_request_edit_cancels_pending_preview(
- self,
- reconstruction_factory: Callable[[], Reconstruction],
- ) -> None:
- controller, logic, session_manager, audio_device_manager = _logic_with_mocks()
- session_manager.autoplay = True
- sample = controller.add_sample(reconstruction_factory(), name="lead")
- logic._pending_autoplay_sample = sample.id
-
- logic.request_edit(sample.id)
- logic._execute_autoplay()
-
- audio_device_manager.play.assert_not_called()
diff --git a/tests/unit/sampletones_application/logic/sequencer/test_voices.py b/tests/unit/sampletones_application/logic/sequencer/test_voices.py
new file mode 100644
index 000000000..1d325244d
--- /dev/null
+++ b/tests/unit/sampletones_application/logic/sequencer/test_voices.py
@@ -0,0 +1,633 @@
+from pathlib import Path
+from typing import Callable, Dict, Tuple
+from unittest.mock import MagicMock
+
+import numpy as np
+import pytest
+
+from sampletones_application.constants.instruments import AUDITION_TICKS
+from sampletones_application.logic.project.controller import ProjectController
+from sampletones_application.logic.project.manager import ProjectManager
+from sampletones_application.logic.sequencer.voices import SequencerVoicesLogic
+from sampletones_application.logic.shared.playback_priority import PlaybackPriority
+from sampletones_application.view_model.sequencer.voices import VoiceKind
+from sampletones_application.view_model.shared.footprint import VoiceFootprintViewModel
+from sampletones_core.configs import Config
+from sampletones_core.constants.enums import ChannelName, FeatureKey
+from sampletones_core.exporters.naming import instrument_slice_name
+from sampletones_core.features.envelope import Envelope
+from sampletones_core.formats.famitracker.footprint import (
+ features_footprint,
+ reconstruction_footprints,
+)
+from sampletones_core.formats.famitracker.instrument import write_fti
+from sampletones_core.formats.famitracker.model.instrument import Instrument2A03
+from sampletones_core.formats.famitracker.model.sequence import InstrumentSequence
+from sampletones_core.formats.famitracker.specification.instruments import (
+ STANDALONE_INSTRUMENT_INDEX,
+)
+from sampletones_core.formats.famitracker.specification.sequences import SequenceKind
+from sampletones_core.formats.famitracker.voice import InstrumentOmission
+from sampletones_core.project.voices.instrument import Instrument
+from sampletones_core.project.voices.note_on import NoteOn
+from sampletones_core.reconstructions import Reconstruction
+from sampletones_shared.exceptions import LoadInstrumentError
+from tests.suite.sequencer import sample_reconstruction
+
+
+def _logic() -> Tuple[ProjectController, SequencerVoicesLogic]:
+ controller = ProjectController(ProjectManager())
+ logic = SequencerVoicesLogic(
+ controller,
+ MagicMock(),
+ MagicMock(),
+ scheduling=MagicMock(),
+ )
+ return controller, logic
+
+
+def _logic_with_mocks() -> Tuple[
+ ProjectController,
+ SequencerVoicesLogic,
+ MagicMock,
+ MagicMock,
+]:
+ controller = ProjectController(ProjectManager())
+ session_manager = MagicMock()
+ audio_device_manager = MagicMock()
+ logic = SequencerVoicesLogic(
+ controller,
+ session_manager,
+ audio_device_manager,
+ scheduling=MagicMock(),
+ )
+ return controller, logic, session_manager, audio_device_manager
+
+
+def _place_instrument(
+ controller: ProjectController,
+ channel: ChannelName,
+ voice_id: str,
+) -> None:
+ pattern_index = controller.project.song.order[0][channel]
+ controller.set_row(
+ channel,
+ pattern_index,
+ 0,
+ command=NoteOn(voice_id=voice_id),
+ )
+
+
+class TestSampleName:
+ def test_returns_the_voice_name(
+ self,
+ reconstruction_factory: Callable[[], Reconstruction],
+ ) -> None:
+ controller, logic = _logic()
+ sample = controller.add_sample(reconstruction_factory(), name="lead")
+ assert logic.voice_name(sample.id) == "lead"
+
+
+class TestWhichKindAVoiceIs:
+ def test_a_recording_answers_as_a_sample(
+ self,
+ reconstruction_factory: Callable[[], Reconstruction],
+ ) -> None:
+ controller, logic = _logic()
+ sample = controller.add_sample(reconstruction_factory(), name="lead")
+
+ assert logic.voice_kind(sample.id) is VoiceKind.SAMPLE
+
+ def test_a_written_voice_answers_as_an_instrument(self) -> None:
+ controller, logic = _logic()
+ instrument = controller.add_instrument(Instrument(name="pad"))
+
+ assert logic.voice_kind(instrument.id) is VoiceKind.INSTRUMENT
+
+ def test_a_voice_the_pool_does_not_hold_answers_with_nothing(self) -> None:
+ _, logic = _logic()
+
+ assert logic.voice_kind("a-voice-no-project-holds") is None
+
+
+class TestIsSampleUsed:
+ def test_false_for_unreferenced_sample(
+ self,
+ reconstruction_factory: Callable[[], Reconstruction],
+ ) -> None:
+ controller, logic = _logic()
+ sample = controller.add_sample(reconstruction_factory(), name="lead")
+ assert logic.is_voice_used(sample.id) is False
+
+ def test_true_after_placing_in_a_pattern(
+ self,
+ reconstruction_factory: Callable[[], Reconstruction],
+ ) -> None:
+ controller, logic = _logic()
+ sample = controller.add_sample(reconstruction_factory(), name="lead")
+ _place_instrument(controller, ChannelName.PULSE1, sample.id)
+ assert logic.is_voice_used(sample.id) is True
+
+
+class TestRemoveSample:
+ def test_removes_unused_sample_from_pool(
+ self,
+ reconstruction_factory: Callable[[], Reconstruction],
+ ) -> None:
+ controller, logic = _logic()
+ sample = controller.add_sample(reconstruction_factory(), name="lead")
+
+ logic.remove_voice(sample.id)
+
+ assert controller.project.voice(sample.id) is None
+
+ def test_removing_used_sample_clears_its_references(
+ self, reconstruction_factory: Callable[[], Reconstruction]
+ ) -> None:
+ controller, logic = _logic()
+ sample = controller.add_sample(reconstruction_factory(), name="lead")
+ _place_instrument(controller, ChannelName.PULSE1, sample.id)
+
+ logic.remove_voice(sample.id)
+
+ assert controller.project.voice(sample.id) is None
+ assert logic.is_voice_used(sample.id) is False
+
+
+class TestMoveSample:
+ def test_move_sample_reorders_pool(
+ self,
+ reconstruction_factory: Callable[[], Reconstruction],
+ ) -> None:
+ controller, logic = _logic()
+ first = controller.add_sample(reconstruction_factory(), name="first")
+ controller.add_sample(reconstruction_factory(), name="second")
+
+ logic.move_voice(first.id, 1)
+
+ assert [sample.name for sample in controller.project.voices] == [
+ "second",
+ "first",
+ ]
+
+
+class TestDuplicateSample:
+ def test_duplicate_sample_appends_copy(
+ self,
+ reconstruction_factory: Callable[[], Reconstruction],
+ ) -> None:
+ controller, logic = _logic()
+ source = controller.add_sample(reconstruction_factory(), name="lead")
+
+ logic.duplicate_voice(source.id)
+
+ assert [sample.name for sample in controller.project.voices] == [
+ "lead",
+ "lead",
+ ]
+
+
+class TestBuildSamples:
+ def test_lists_added_samples_in_insertion_order(
+ self,
+ reconstruction_factory: Callable[[], Reconstruction],
+ ) -> None:
+ controller, logic = _logic()
+ first = controller.add_sample(reconstruction_factory(), name="first")
+ second = controller.add_sample(reconstruction_factory(), name="second")
+
+ view_model = logic.build_voices()
+
+ assert [entry.voice_id for entry in view_model.voices] == [
+ first.id,
+ second.id,
+ ]
+ assert [entry.name for entry in view_model.voices] == [
+ "first",
+ "second",
+ ]
+
+
+class TestBuildSampleFootprint:
+ """The samples menu prints what a sample occupies, measured the way the sample is placed."""
+
+ def test_it_names_each_playing_channel(self) -> None:
+ controller, logic = _logic()
+ channels = (ChannelName.PULSE1, ChannelName.TRIANGLE)
+ sample = controller.add_sample(sample_reconstruction(channels), name="bell")
+
+ footprint = logic.build_voice_footprint(sample.id)
+
+ assert footprint is not None
+ assert [instrument.channel for instrument in footprint.instruments] == list(channels)
+
+ def test_it_measures_the_sample_as_its_own_export_writes_it(
+ self,
+ reconstruction_factory: Callable[[], Reconstruction],
+ ) -> None:
+ controller, logic = _logic()
+ sample = controller.add_sample(reconstruction_factory(), name="lead")
+
+ footprint = logic.build_voice_footprint(sample.id)
+
+ assert footprint == VoiceFootprintViewModel.from_footprints(reconstruction_footprints(sample.reconstruction))
+
+ def test_each_channel_is_measured_as_the_instrument_it_sounds(self) -> None:
+ """A channel's figure is the cost of its own instrument, and the channels differ.
+
+ The triangle states a pitch alone where the pulse states a level and a waveform too, so
+ the same frame written on each costs the triangle the less.
+ """
+ controller, logic = _logic()
+ channels = (ChannelName.PULSE1, ChannelName.TRIANGLE)
+ sample = controller.add_sample(sample_reconstruction(channels), name="bell")
+
+ footprint = logic.build_voice_footprint(sample.id)
+
+ assert footprint is not None
+ assert footprint.bytes_for(ChannelName.TRIANGLE) < footprint.bytes_for(ChannelName.PULSE1)
+
+ def test_a_sample_the_pool_has_dropped_is_measured_nowhere(self) -> None:
+ _, logic = _logic()
+
+ assert logic.build_voice_footprint("missing") is None
+
+
+class TestTakingAChannelAsAnInstrument:
+ """A recording's channel is read back as envelopes, so what it played becomes a voice to edit."""
+
+ def test_every_channel_that_plays_is_offered(self) -> None:
+ controller, logic = _logic()
+ channels = (ChannelName.PULSE1, ChannelName.TRIANGLE)
+ sample = controller.add_sample(sample_reconstruction(channels), name="bell")
+
+ assert logic.instrument_channels(sample.id) == channels
+
+ def test_a_voice_already_written_as_envelopes_offers_none(self) -> None:
+ """It is what this would make of it, so there is nothing to take out of it."""
+ controller, logic = _logic()
+ instrument = logic.add_new_instrument("lead")
+
+ assert logic.instrument_channels(instrument.id) == ()
+
+ def test_a_voice_the_pool_has_dropped_offers_none(self) -> None:
+ _, logic = _logic()
+
+ assert logic.instrument_channels("missing") == ()
+
+ def test_the_envelopes_come_across_as_the_channel_played_them(self) -> None:
+ controller, logic = _logic()
+ sample = controller.add_sample(sample_reconstruction({ChannelName.TRIANGLE}), name="bell")
+ played = sample.reconstruction.export()[ChannelName.TRIANGLE]
+
+ instrument = logic.instrument_from_channel(sample.id, ChannelName.TRIANGLE)
+
+ assert instrument is not None
+ assert instrument.envelopes.volume == played.volume
+ assert instrument.envelopes.arpeggio == played.arpeggio
+
+ def test_the_instrument_is_measured_against_the_reference_that_channel_read(self) -> None:
+ controller, logic = _logic()
+ sample = controller.add_sample(sample_reconstruction({ChannelName.NOISE}), name="bell")
+ played = sample.reconstruction.export()[ChannelName.NOISE]
+
+ instrument = logic.instrument_from_channel(sample.id, ChannelName.NOISE)
+
+ assert instrument is not None
+ assert instrument.reference(ChannelName.NOISE) == played.initial_pitch
+
+ def test_the_instrument_is_named_after_the_channel_it_came_from(self) -> None:
+ """A slice carries the name an export gives it, so the list says where the voice came from."""
+ controller, logic = _logic()
+ sample = controller.add_sample(sample_reconstruction({ChannelName.NOISE}), name="bell")
+
+ instrument = logic.instrument_from_channel(sample.id, ChannelName.NOISE)
+
+ assert instrument is not None
+ assert instrument.name == instrument_slice_name("bell", ChannelName.NOISE)
+
+ def test_the_instrument_holds_each_dimension_out_past_its_items(self) -> None:
+ """A recorded channel rests on the value it last played, which is what a halt states."""
+ controller, logic = _logic()
+ sample = controller.add_sample(sample_reconstruction({ChannelName.PULSE1}), name="bell")
+
+ instrument = logic.instrument_from_channel(sample.id, ChannelName.PULSE1)
+
+ assert instrument is not None
+ assert all(envelope.loop_point is None for envelope in instrument.envelopes.envelope_map.values())
+
+ def test_taking_a_channel_leaves_the_pool_as_it_stands(self) -> None:
+ """The instrument is written here and added by whoever asked, inside a history entry."""
+ controller, logic = _logic()
+ sample = controller.add_sample(sample_reconstruction({ChannelName.PULSE1}), name="bell")
+
+ logic.instrument_from_channel(sample.id, ChannelName.PULSE1)
+
+ assert controller.voice_count == 1
+
+ def test_a_channel_standing_by_makes_nothing(self) -> None:
+ controller, logic = _logic()
+ sample = controller.add_sample(sample_reconstruction({ChannelName.PULSE1}), name="bell")
+
+ assert logic.instrument_from_channel(sample.id, ChannelName.NOISE) is None
+
+ def test_a_voice_the_pool_has_dropped_makes_nothing(self) -> None:
+ _, logic = _logic()
+
+ assert logic.instrument_from_channel("missing", ChannelName.PULSE1) is None
+
+
+class TestPlaySample:
+ def test_plays_reconstruction_regardless_of_autoplay(
+ self, reconstruction_factory: Callable[[], Reconstruction]
+ ) -> None:
+ controller, logic, session_manager, audio_device_manager = _logic_with_mocks()
+ session_manager.autoplay = False
+ sample = controller.add_sample(reconstruction_factory(), name="lead")
+
+ logic.play_voice(sample.id)
+
+ audio_device_manager.play.assert_called_once()
+ call = audio_device_manager.play.call_args
+ assert np.array_equal(call.args[0], sample.reconstruction.approximation)
+ assert call.kwargs["priority"] == PlaybackPriority.NORMAL
+ assert call.kwargs["update"] is False
+
+ def test_unknown_sample_is_ignored(self) -> None:
+ _, logic, _, audio_device_manager = _logic_with_mocks()
+
+ logic.play_voice("missing")
+
+ audio_device_manager.play.assert_not_called()
+
+
+class TestAutoplay:
+ def test_executes_pending_preview_when_autoplay_enabled(
+ self, reconstruction_factory: Callable[[], Reconstruction]
+ ) -> None:
+ controller, logic, session_manager, audio_device_manager = _logic_with_mocks()
+ session_manager.autoplay = True
+ sample = controller.add_sample(reconstruction_factory(), name="lead")
+ logic._pending_autoplay_voice = sample.id
+
+ logic._execute_autoplay()
+
+ audio_device_manager.play.assert_called_once()
+ assert audio_device_manager.play.call_args.kwargs["priority"] == PlaybackPriority.PREVIEW
+
+ def test_skips_pending_preview_when_autoplay_disabled(
+ self,
+ reconstruction_factory: Callable[[], Reconstruction],
+ ) -> None:
+ controller, logic, session_manager, audio_device_manager = _logic_with_mocks()
+ session_manager.autoplay = False
+ sample = controller.add_sample(reconstruction_factory(), name="lead")
+ logic._pending_autoplay_voice = sample.id
+
+ logic._execute_autoplay()
+
+ audio_device_manager.play.assert_not_called()
+
+ def test_cancel_autoplay_drops_pending_preview(
+ self,
+ reconstruction_factory: Callable[[], Reconstruction],
+ ) -> None:
+ controller, logic, session_manager, audio_device_manager = _logic_with_mocks()
+ session_manager.autoplay = True
+ sample = controller.add_sample(reconstruction_factory(), name="lead")
+ logic._pending_autoplay_voice = sample.id
+
+ logic.cancel_autoplay()
+ logic._execute_autoplay()
+
+ audio_device_manager.play.assert_not_called()
+
+ def test_request_edit_cancels_pending_preview(
+ self,
+ reconstruction_factory: Callable[[], Reconstruction],
+ ) -> None:
+ controller, logic, session_manager, audio_device_manager = _logic_with_mocks()
+ session_manager.autoplay = True
+ sample = controller.add_sample(reconstruction_factory(), name="lead")
+ logic._pending_autoplay_voice = sample.id
+
+ logic.request_edit(sample.id)
+ logic._execute_autoplay()
+
+ audio_device_manager.play.assert_not_called()
+
+
+class TestInstrumentsInTheVoiceList:
+ """A hand-written voice sits in the same list as a converted one, marked by its kind."""
+
+ def test_an_instrument_is_listed_beside_the_samples_that_were_added(
+ self,
+ reconstruction_factory: Callable[[], Reconstruction],
+ ) -> None:
+ controller, logic = _logic()
+ sample = controller.add_sample(reconstruction_factory(), name="bass")
+ instrument = logic.add_new_instrument("lead")
+
+ entries = logic.build_voices().voices
+
+ assert [(entry.voice_id, entry.kind) for entry in entries] == [
+ (sample.id, VoiceKind.SAMPLE),
+ (instrument.id, VoiceKind.INSTRUMENT),
+ ]
+
+ def test_an_instrument_is_measured_as_the_one_export_it_writes(self) -> None:
+ controller, logic = _logic()
+ instrument = logic.add_new_instrument("lead")
+ controller.set_instrument_envelope(instrument.id, FeatureKey.VOLUME, Envelope(items=(15, 12, 9)))
+
+ footprint = logic.build_voice_footprint(instrument.id)
+
+ assert footprint is not None
+ assert footprint.total_bytes == features_footprint(instrument.instrument_features()).total_bytes
+ assert [instrument.channel for instrument in footprint.instruments] == [None]
+
+ def test_an_instrument_previews_through_the_pulse_channel(self) -> None:
+ controller, logic, session_manager, audio_device_manager = _logic_with_mocks()
+ instrument = logic.add_new_instrument("lead")
+ controller.set_instrument_envelope(instrument.id, FeatureKey.VOLUME, Envelope(items=(15, 12)))
+
+ logic.play_voice(instrument.id)
+
+ played = audio_device_manager.play.call_args.args[0]
+ assert played.size > 0
+
+ def test_a_sustaining_instrument_previews_for_the_length_a_voice_is_offered(self) -> None:
+ """A voice that never releases would otherwise preview as the single frame it writes."""
+ controller, logic, _, audio_device_manager = _logic_with_mocks()
+ instrument = logic.add_new_instrument("lead")
+
+ logic.play_voice(instrument.id)
+
+ played = audio_device_manager.play.call_args.args[0]
+ frame_length = _preview_config(controller).frame_length
+ assert played.size == AUDITION_TICKS * frame_length
+
+ def test_an_instrument_releasing_itself_previews_only_that_far(self) -> None:
+ controller, logic, _, audio_device_manager = _logic_with_mocks()
+ instrument = logic.add_new_instrument("lead")
+ controller.set_instrument_envelope(instrument.id, FeatureKey.VOLUME, Envelope(items=(15, 8, 0)))
+
+ logic.play_voice(instrument.id)
+
+ played = audio_device_manager.play.call_args.args[0]
+ frame_length = _preview_config(controller).frame_length
+ assert played.size == 3 * frame_length
+
+ def test_an_instrument_writing_nothing_sounds_no_preview(self) -> None:
+ controller, logic, _, audio_device_manager = _logic_with_mocks()
+ instrument = controller.add_instrument(Instrument(name="lead"))
+
+ logic.play_voice(instrument.id)
+
+ audio_device_manager.play.assert_not_called()
+
+
+def _preview_config(controller: ProjectController) -> Config:
+ """The configuration a preview renders at, which is the project's own rate and sample rate."""
+ settings = controller.project.settings
+ return Config().with_library(
+ nes_frequency=settings.nes_frequency,
+ sample_rate=settings.sample_rate,
+ )
+
+
+def _tracker_instrument(
+ name: str,
+ *sequences: InstrumentSequence,
+) -> Instrument2A03:
+ """A 2A03 instrument as a ``.fti`` holds one: every sequence stated, most of them empty."""
+ written: Dict[SequenceKind, InstrumentSequence] = {kind: InstrumentSequence(kind=kind) for kind in SequenceKind}
+ for sequence in sequences:
+ written[sequence.kind] = sequence
+
+ return Instrument2A03(
+ index=STANDALONE_INSTRUMENT_INDEX,
+ name=name,
+ sequences=written,
+ )
+
+
+def _instrument_file(
+ directory: Path,
+ filename: str,
+ name: str,
+ *sequences: InstrumentSequence,
+) -> Path:
+ filepath = directory / filename
+ write_fti(filepath, _tracker_instrument(name, *sequences))
+ return filepath
+
+
+class TestReadingAnInstrumentFile:
+ """A ``.fti`` FamiTracker wrote arrives as a voice the pool holds like any other."""
+
+ def test_the_envelopes_the_file_states_reach_the_voice(self, tmp_path: Path) -> None:
+ filepath = _instrument_file(
+ tmp_path,
+ "Lead.fti",
+ "Lead",
+ InstrumentSequence(kind=SequenceKind.VOLUME, items=(15, 8, 0)),
+ InstrumentSequence(kind=SequenceKind.ARPEGGIO, items=(0, 3, 7)),
+ )
+ _, logic = _logic()
+
+ voice = logic.read_instrument(filepath).voice
+
+ assert voice.envelopes.volume.items == (15, 8, 0)
+ assert voice.envelopes.arpeggio.items == (0, 3, 7)
+
+ def test_the_file_names_the_voice(self, tmp_path: Path) -> None:
+ filepath = _instrument_file(
+ tmp_path,
+ "whatever.fti",
+ "Lead",
+ InstrumentSequence(kind=SequenceKind.VOLUME, items=(15,)),
+ )
+ _, logic = _logic()
+
+ assert logic.read_instrument(filepath).voice.name == "Lead"
+
+ def test_a_file_naming_nothing_leaves_the_voice_named_after_it(self, tmp_path: Path) -> None:
+ """A nameless entry reads as nothing in the list, so the file it came from names it."""
+ filepath = _instrument_file(
+ tmp_path,
+ "Bass Line.fti",
+ "",
+ InstrumentSequence(kind=SequenceKind.VOLUME, items=(15,)),
+ )
+ _, logic = _logic()
+
+ assert logic.read_instrument(filepath).voice.name == "Bass Line"
+
+ def test_what_the_file_states_past_the_voice_comes_back_with_it(self, tmp_path: Path) -> None:
+ filepath = _instrument_file(
+ tmp_path,
+ "Lead.fti",
+ "Lead",
+ InstrumentSequence(kind=SequenceKind.VOLUME, items=(15, 8)),
+ InstrumentSequence(kind=SequenceKind.PITCH, items=(1, -1)),
+ )
+ _, logic = _logic()
+
+ assert InstrumentOmission.CUMULATIVE_BEND in logic.read_instrument(filepath).omissions
+
+ def test_reading_leaves_the_pool_as_it_stands(self, tmp_path: Path) -> None:
+ """The pool is edited by the gesture that adds, so a read alone records no history entry."""
+ filepath = _instrument_file(
+ tmp_path,
+ "Lead.fti",
+ "Lead",
+ InstrumentSequence(kind=SequenceKind.VOLUME, items=(15,)),
+ )
+ controller, logic = _logic()
+
+ logic.read_instrument(filepath)
+
+ assert list(controller.project.voices) == []
+
+ def test_the_voice_the_file_made_joins_the_pool(self, tmp_path: Path) -> None:
+ filepath = _instrument_file(
+ tmp_path,
+ "Lead.fti",
+ "Lead",
+ InstrumentSequence(kind=SequenceKind.VOLUME, items=(15,)),
+ )
+ controller, logic = _logic()
+
+ voice = logic.add_instrument(logic.read_instrument(filepath).voice)
+
+ assert [entry.voice_id for entry in logic.build_voices().voices] == [voice.id]
+ assert controller.project.voices.get(voice.id) is voice
+
+ def test_a_file_of_another_kind_is_refused(self, tmp_path: Path) -> None:
+ filepath = tmp_path / "notes.fti"
+ filepath.write_bytes(b"not an instrument file at all")
+ _, logic = _logic()
+
+ with pytest.raises(LoadInstrumentError):
+ logic.read_instrument(filepath)
+
+ def test_a_file_that_ends_inside_its_own_layout_is_refused(self, tmp_path: Path) -> None:
+ whole = _instrument_file(
+ tmp_path,
+ "Lead.fti",
+ "Lead",
+ InstrumentSequence(kind=SequenceKind.VOLUME, items=(15, 8, 0)),
+ )
+ truncated = tmp_path / "Short.fti"
+ truncated.write_bytes(whole.read_bytes()[:12])
+ _, logic = _logic()
+
+ with pytest.raises(LoadInstrumentError):
+ logic.read_instrument(truncated)
+
+ def test_a_file_that_is_not_there_is_reported_as_missing(self, tmp_path: Path) -> None:
+ _, logic = _logic()
+
+ with pytest.raises(FileNotFoundError):
+ logic.read_instrument(tmp_path / "nowhere.fti")
diff --git a/tests/unit/sampletones_application/logic/sequencer/tracker/test_adjuster.py b/tests/unit/sampletones_application/logic/sequencer/tracker/test_adjuster.py
index 20da9fe32..745b78732 100644
--- a/tests/unit/sampletones_application/logic/sequencer/tracker/test_adjuster.py
+++ b/tests/unit/sampletones_application/logic/sequencer/tracker/test_adjuster.py
@@ -12,13 +12,16 @@
from sampletones_application.view_model.sequencer.region import TrackerRegion
from sampletones_application.view_model.sequencer.slot import TrackerSlot
from sampletones_application.view_model.sequencer.subcolumn import SubColumn
-from sampletones_core.constants.enums import GeneratorName
+from sampletones_core.constants.enums import ChannelName
+from sampletones_core.constants.general import MAX_TRANSPOSE
+from sampletones_core.utils.display import display_transpose
from tests.suite.base import BaseTestSuite
from tests.suite.case import BaseRegularTestCase
from tests.suite.sequencer import fill_frame, render_frame, sample_reconstruction
FRAME_ROWS: Final[int] = 3
EMPTY: Final[str] = ".. ... . | .. ... . | .. ... . | .. ... ."
+HIGHEST: Final[str] = display_transpose(MAX_TRANSPOSE)
LEAD: Final[str] = "00"
@@ -29,7 +32,7 @@ class Grid:
controller: ProjectController
logic: SequencerTrackerLogic
adjuster: TrackerRegionAdjuster
- sample_ids: Tuple[str, ...]
+ voice_ids: Tuple[str, ...]
@pytest.fixture
@@ -43,20 +46,20 @@ def grid() -> Grid:
logic = SequencerTrackerLogic(controller)
logic.set_rows_per_pattern(FRAME_ROWS)
lead = controller.add_sample(
- sample_reconstruction([GeneratorName.PULSE1, GeneratorName.PULSE2]),
+ sample_reconstruction([ChannelName.PULSE1, ChannelName.PULSE2]),
name="lead",
)
return Grid(
controller=controller,
logic=logic,
adjuster=TrackerRegionAdjuster(logic),
- sample_ids=(lead.id,),
+ voice_ids=(lead.id,),
)
def _region(
- first: Tuple[Optional[GeneratorName], SubColumn],
- last: Tuple[Optional[GeneratorName], SubColumn],
+ first: Tuple[Optional[ChannelName], SubColumn],
+ last: Tuple[Optional[ChannelName], SubColumn],
*,
first_row: int = 0,
last_row: int = 0,
@@ -88,8 +91,8 @@ class TestCase(BaseRegularTestCase):
TestCase(
label="a cell alone shifts its own channel",
region=_region(
- (GeneratorName.PULSE1, SubColumn.INSTRUMENT),
- (GeneratorName.PULSE1, SubColumn.INSTRUMENT),
+ (ChannelName.PULSE1, SubColumn.VOICE),
+ (ChannelName.PULSE1, SubColumn.VOICE),
),
delta=1,
expected=(
@@ -101,8 +104,8 @@ class TestCase(BaseRegularTestCase):
TestCase(
label="a region standing on another subcolumn still shifts the transpose",
region=_region(
- (GeneratorName.TRIANGLE, SubColumn.VOLUME),
- (GeneratorName.TRIANGLE, SubColumn.VOLUME),
+ (ChannelName.TRIANGLE, SubColumn.VOLUME),
+ (ChannelName.TRIANGLE, SubColumn.VOLUME),
),
delta=-1,
expected=(
@@ -115,8 +118,8 @@ class TestCase(BaseRegularTestCase):
label="a shift adds to the transpose a cell already holds",
frame=(".. +02 . | .. ... . | .. ... . | .. ... .",),
region=_region(
- (GeneratorName.PULSE1, SubColumn.TRANSPOSE),
- (GeneratorName.PULSE1, SubColumn.TRANSPOSE),
+ (ChannelName.PULSE1, SubColumn.TRANSPOSE),
+ (ChannelName.PULSE1, SubColumn.TRANSPOSE),
),
delta=12,
expected=(
@@ -128,8 +131,8 @@ class TestCase(BaseRegularTestCase):
TestCase(
label="a region across columns shifts each of them",
region=_region(
- (GeneratorName.PULSE2, SubColumn.VOLUME),
- (GeneratorName.NOISE, SubColumn.INSTRUMENT),
+ (ChannelName.PULSE2, SubColumn.VOLUME),
+ (ChannelName.NOISE, SubColumn.VOICE),
),
delta=1,
expected=(
@@ -141,8 +144,8 @@ class TestCase(BaseRegularTestCase):
TestCase(
label="a region across rows shifts each of them",
region=_region(
- (GeneratorName.PULSE1, SubColumn.INSTRUMENT),
- (GeneratorName.PULSE1, SubColumn.INSTRUMENT),
+ (ChannelName.PULSE1, SubColumn.VOICE),
+ (ChannelName.PULSE1, SubColumn.VOICE),
first_row=1,
last_row=2,
),
@@ -156,7 +159,7 @@ class TestCase(BaseRegularTestCase):
TestCase(
label="an ungoverned sample column reaches every channel",
region=_region(
- (None, SubColumn.INSTRUMENT),
+ (None, SubColumn.VOICE),
(None, SubColumn.VOLUME),
),
delta=3,
@@ -170,7 +173,7 @@ class TestCase(BaseRegularTestCase):
label="a governed sample column reaches the channels its sample uses",
frame=(f"{LEAD} ... . | {LEAD} ... . | .. ... . | .. ... .",),
region=_region(
- (None, SubColumn.INSTRUMENT),
+ (None, SubColumn.VOICE),
(None, SubColumn.VOLUME),
),
delta=3,
@@ -184,8 +187,8 @@ class TestCase(BaseRegularTestCase):
label="a channel covered beside the sample column moves a single step",
frame=(f"{LEAD} ... . | {LEAD} ... . | .. ... . | .. ... .",),
region=_region(
- (None, SubColumn.INSTRUMENT),
- (GeneratorName.PULSE1, SubColumn.VOLUME),
+ (None, SubColumn.VOICE),
+ (ChannelName.PULSE1, SubColumn.VOLUME),
),
delta=1,
expected=(
@@ -196,14 +199,14 @@ class TestCase(BaseRegularTestCase):
),
TestCase(
label="a shift stops at the transpose range",
- frame=(".. +20 . | .. ... . | .. ... . | .. ... .",),
+ frame=(f".. {HIGHEST} . | .. ... . | .. ... . | .. ... .",),
region=_region(
- (GeneratorName.PULSE1, SubColumn.TRANSPOSE),
- (GeneratorName.PULSE1, SubColumn.TRANSPOSE),
+ (ChannelName.PULSE1, SubColumn.TRANSPOSE),
+ (ChannelName.PULSE1, SubColumn.TRANSPOSE),
),
delta=12,
expected=(
- ".. +24 . | .. ... . | .. ... . | .. ... .",
+ f".. {HIGHEST} . | .. ... . | .. ... . | .. ... .",
EMPTY,
EMPTY,
),
@@ -220,7 +223,7 @@ def test_the_frame_after_a_shift(
grid: Grid,
test_case: TestCase,
) -> None:
- fill_frame(grid.logic, test_case.frame, sample_ids=grid.sample_ids)
+ fill_frame(grid.logic, test_case.frame, voice_ids=grid.voice_ids)
grid.adjuster.adjust_transpose(test_case.region, test_case.delta)
@@ -241,8 +244,8 @@ class TestCase(BaseRegularTestCase):
TestCase(
label="an unset cell steps down from full",
region=_region(
- (GeneratorName.PULSE1, SubColumn.INSTRUMENT),
- (GeneratorName.PULSE1, SubColumn.INSTRUMENT),
+ (ChannelName.PULSE1, SubColumn.VOICE),
+ (ChannelName.PULSE1, SubColumn.VOICE),
),
delta=-1,
expected=(
@@ -255,8 +258,8 @@ class TestCase(BaseRegularTestCase):
label="a coarse step moves the whole region",
frame=(".. ... 8 | .. ... 8 | .. ... . | .. ... .",),
region=_region(
- (GeneratorName.PULSE1, SubColumn.VOLUME),
- (GeneratorName.PULSE2, SubColumn.VOLUME),
+ (ChannelName.PULSE1, SubColumn.VOLUME),
+ (ChannelName.PULSE2, SubColumn.VOLUME),
),
delta=-4,
expected=(
@@ -269,8 +272,8 @@ class TestCase(BaseRegularTestCase):
label="a shift stops at silence",
frame=(".. ... 1 | .. ... . | .. ... . | .. ... .",),
region=_region(
- (GeneratorName.PULSE1, SubColumn.VOLUME),
- (GeneratorName.PULSE1, SubColumn.VOLUME),
+ (ChannelName.PULSE1, SubColumn.VOLUME),
+ (ChannelName.PULSE1, SubColumn.VOLUME),
),
delta=-4,
expected=(
@@ -283,8 +286,8 @@ class TestCase(BaseRegularTestCase):
label="a channel covered beside the sample column moves a single step",
frame=(f"{LEAD} ... 8 | {LEAD} ... 8 | .. ... . | .. ... .",),
region=_region(
- (None, SubColumn.INSTRUMENT),
- (GeneratorName.PULSE1, SubColumn.VOLUME),
+ (None, SubColumn.VOICE),
+ (ChannelName.PULSE1, SubColumn.VOLUME),
),
delta=-1,
expected=(
@@ -305,7 +308,7 @@ def test_the_frame_after_a_shift(
grid: Grid,
test_case: TestCase,
) -> None:
- fill_frame(grid.logic, test_case.frame, sample_ids=grid.sample_ids)
+ fill_frame(grid.logic, test_case.frame, voice_ids=grid.voice_ids)
grid.adjuster.adjust_volume(test_case.region, test_case.delta)
diff --git a/tests/unit/sampletones_application/logic/sequencer/tracker/test_pitch_faces.py b/tests/unit/sampletones_application/logic/sequencer/tracker/test_pitch_faces.py
new file mode 100644
index 000000000..e58be918c
--- /dev/null
+++ b/tests/unit/sampletones_application/logic/sequencer/tracker/test_pitch_faces.py
@@ -0,0 +1,141 @@
+from typing import Final, Optional, Tuple
+
+from sampletones_application.logic.project.controller import ProjectController
+from sampletones_application.logic.project.manager import ProjectManager
+from sampletones_application.logic.sequencer.tracker import SequencerTrackerLogic
+from sampletones_core.constants.enums import ChannelName
+from sampletones_core.features.envelope import Envelope
+from sampletones_core.project.voices.creation import new_instrument
+from sampletones_core.project.voices.envelopes import InstrumentEnvelopes
+from sampletones_core.project.voices.instrument import Instrument
+from sampletones_core.project.voices.note_off import NoteOff
+from sampletones_core.project.voices.note_on import NoteOn
+from sampletones_core.utils.display import NOTE_BLANK, display_transpose
+from sampletones_core.utils.frequencies import period_to_name, pitch_to_name
+from tests.suite.sequencer import sample_reconstruction
+
+ROOT_PITCH: Final[int] = 60
+ROOT_PERIOD: Final[int] = 5
+TRANSPOSE: Final[int] = 4
+BEND: Final[int] = 7
+
+
+def _logic() -> Tuple[ProjectController, SequencerTrackerLogic]:
+ controller = ProjectController(ProjectManager())
+ return controller, SequencerTrackerLogic(controller)
+
+
+def _instrument(controller: ProjectController) -> Instrument:
+ return controller.add_instrument(
+ Instrument(
+ name="lead",
+ envelopes=InstrumentEnvelopes(volume=Envelope(items=(15,))),
+ initial_pitch=ROOT_PITCH,
+ initial_period=ROOT_PERIOD,
+ )
+ )
+
+
+def _write(
+ controller: ProjectController,
+ channel: ChannelName,
+ row_index: int,
+ *,
+ command: Optional[object] = None,
+ transpose: Optional[int] = None,
+) -> None:
+ pattern_index = controller.project.song.order[0][channel]
+ controller.set_row(
+ channel,
+ pattern_index,
+ row_index,
+ command=command,
+ transpose=transpose,
+ )
+
+
+def _pitch_cell(logic: SequencerTrackerLogic, channel: ChannelName, row_index: int) -> str:
+ return logic.build_grid().rows[row_index].cells[channel].transpose
+
+
+class TestACellReadsInTheTermsOfItsVoice:
+ def test_a_sample_reads_as_a_step_from_its_own_pitch(self) -> None:
+ controller, logic = _logic()
+ sample = controller.add_sample(sample_reconstruction([ChannelName.PULSE1]), name="bass")
+ _write(controller, ChannelName.PULSE1, 0, command=NoteOn(voice_id=sample.id), transpose=TRANSPOSE)
+
+ assert _pitch_cell(logic, ChannelName.PULSE1, 0) == display_transpose(TRANSPOSE)
+
+ def test_an_instrument_reads_as_the_note_it_sounds(self) -> None:
+ controller, logic = _logic()
+ instrument = _instrument(controller)
+ _write(controller, ChannelName.PULSE1, 0, command=NoteOn(voice_id=instrument.id), transpose=TRANSPOSE)
+
+ assert _pitch_cell(logic, ChannelName.PULSE1, 0) == pitch_to_name(ROOT_PITCH + TRANSPOSE)
+
+ def test_an_instrument_on_noise_names_its_period(self) -> None:
+ controller, logic = _logic()
+ instrument = _instrument(controller)
+ _write(controller, ChannelName.NOISE, 0, command=NoteOn(voice_id=instrument.id), transpose=TRANSPOSE)
+
+ assert _pitch_cell(logic, ChannelName.NOISE, 0) == period_to_name(ROOT_PERIOD + TRANSPOSE)
+
+ def test_an_empty_cell_reads_blank_whichever_voice_is_carried(self) -> None:
+ controller, logic = _logic()
+ instrument = _instrument(controller)
+ _write(controller, ChannelName.PULSE1, 0, command=NoteOn(voice_id=instrument.id), transpose=TRANSPOSE)
+
+ assert _pitch_cell(logic, ChannelName.PULSE1, 1) == NOTE_BLANK
+
+
+class TestTheFaceFollowsTheVoiceTheChannelCarries:
+ def test_a_bend_below_an_instrument_still_reads_as_a_note(self) -> None:
+ """A row bending a note it did not start reads in the terms of the voice in force."""
+ controller, logic = _logic()
+ instrument = _instrument(controller)
+ _write(controller, ChannelName.PULSE1, 0, command=NoteOn(voice_id=instrument.id), transpose=0)
+ _write(controller, ChannelName.PULSE1, 1, transpose=BEND)
+
+ assert _pitch_cell(logic, ChannelName.PULSE1, 1) == pitch_to_name(ROOT_PITCH + BEND)
+
+ def test_a_bend_below_a_sample_still_reads_as_a_step(self) -> None:
+ controller, logic = _logic()
+ sample = controller.add_sample(sample_reconstruction([ChannelName.PULSE1]), name="bass")
+ _write(controller, ChannelName.PULSE1, 0, command=NoteOn(voice_id=sample.id), transpose=0)
+ _write(controller, ChannelName.PULSE1, 1, transpose=BEND)
+
+ assert _pitch_cell(logic, ChannelName.PULSE1, 1) == display_transpose(BEND)
+
+ def test_a_note_off_hands_the_column_back_to_the_neutral_face(self) -> None:
+ controller, logic = _logic()
+ instrument = _instrument(controller)
+ _write(controller, ChannelName.PULSE1, 0, command=NoteOn(voice_id=instrument.id), transpose=0)
+ _write(controller, ChannelName.PULSE1, 1, command=NoteOff())
+ _write(controller, ChannelName.PULSE1, 2, transpose=BEND)
+
+ assert _pitch_cell(logic, ChannelName.PULSE1, 2) == display_transpose(BEND)
+
+ def test_a_frame_naming_no_voice_reads_as_a_step(self) -> None:
+ controller, logic = _logic()
+ _write(controller, ChannelName.PULSE1, 0, transpose=BEND)
+
+ assert _pitch_cell(logic, ChannelName.PULSE1, 0) == display_transpose(BEND)
+
+
+class TestTheSampleColumnSpeaksForSamples:
+ def test_it_declines_an_instrument(self) -> None:
+ controller, logic = _logic()
+ instrument = _instrument(controller)
+
+ logic.set_row_sample(0, instrument.id)
+
+ assert all(logic.row(channel, 0) is None or logic.row(channel, 0).is_empty() for channel in ChannelName.items())
+
+ def test_it_reads_mixed_where_the_channels_disagree_on_the_face(self) -> None:
+ controller, logic = _logic()
+ instrument = _instrument(controller)
+ sample = controller.add_sample(sample_reconstruction(list(ChannelName.items())), name="bass")
+ _write(controller, ChannelName.PULSE1, 0, command=NoteOn(voice_id=instrument.id), transpose=0)
+ _write(controller, ChannelName.PULSE2, 0, command=NoteOn(voice_id=sample.id), transpose=0)
+
+ assert logic.build_grid().rows[0].transpose == "?"
diff --git a/tests/unit/sampletones_application/logic/sequencer/tracker/test_reader.py b/tests/unit/sampletones_application/logic/sequencer/tracker/test_reader.py
index f1518560b..88f03adef 100644
--- a/tests/unit/sampletones_application/logic/sequencer/tracker/test_reader.py
+++ b/tests/unit/sampletones_application/logic/sequencer/tracker/test_reader.py
@@ -11,8 +11,8 @@
from sampletones_application.view_model.sequencer.region import TrackerRegion
from sampletones_application.view_model.sequencer.slot import SUBCOLUMNS, TrackerSlot
from sampletones_application.view_model.sequencer.subcolumn import SubColumn
-from sampletones_core.constants.enums import GeneratorName
-from sampletones_core.project.instruments.note_off import NoteOff
+from sampletones_core.constants.enums import ChannelName
+from sampletones_core.project.voices.note_off import NoteOff
from tests.suite.sequencer import sample_reconstruction
@@ -42,17 +42,17 @@ def reader(logic: SequencerTrackerLogic) -> TrackerBlockReader:
return TrackerBlockReader(logic)
-def _slot(generator: Optional[GeneratorName], subcolumn: SubColumn) -> int:
- return TrackerSlot(generator, subcolumn).flat_index
+def _slot(channel: Optional[ChannelName], subcolumn: SubColumn) -> int:
+ return TrackerSlot(channel, subcolumn).flat_index
def _cell(
row_index: int,
- generator: Optional[GeneratorName],
+ channel: Optional[ChannelName],
subcolumn: SubColumn,
) -> TrackerRegion:
"""The region one subcolumn of one cell covers."""
- slot = _slot(generator, subcolumn)
+ slot = _slot(channel, subcolumn)
return TrackerRegion(
first_row=row_index,
last_row=row_index,
@@ -62,7 +62,7 @@ def _cell(
def _column(
- generator: Optional[GeneratorName],
+ channel: Optional[ChannelName],
*,
last_row: int = 0,
) -> TrackerRegion:
@@ -70,8 +70,8 @@ def _column(
return TrackerRegion(
first_row=0,
last_row=last_row,
- first_slot=_slot(generator, SubColumn.INSTRUMENT),
- last_slot=_slot(generator, SubColumn.VOLUME),
+ first_slot=_slot(channel, SubColumn.VOICE),
+ last_slot=_slot(channel, SubColumn.VOLUME),
)
@@ -85,15 +85,15 @@ def test_a_cell_carries_the_values_it_holds(
reader: TrackerBlockReader,
) -> None:
sample = controller.add_sample(
- sample_reconstruction([GeneratorName.PULSE1]),
+ sample_reconstruction([ChannelName.PULSE1]),
name="lead",
)
- logic.place_note(0, GeneratorName.PULSE1, sample.id)
- logic.set_cell_subcolumn(0, GeneratorName.PULSE1, transpose=5, volume=3)
+ logic.place_note(0, ChannelName.PULSE1, sample.id)
+ logic.set_cell_subcolumn(0, ChannelName.PULSE1, transpose=5, volume=3)
- block = reader.read(_column(GeneratorName.PULSE1))
+ block = reader.read(_column(ChannelName.PULSE1))
- assert block.notes[_key(SubColumn.INSTRUMENT)] == sample.id
+ assert block.notes[_key(SubColumn.VOICE)] == sample.id
assert block.transposes[_key(SubColumn.TRANSPOSE)] == 5
assert block.volumes[_key(SubColumn.VOLUME)] == 3
@@ -102,9 +102,9 @@ def test_an_empty_cell_carries_its_emptiness(
reader: TrackerBlockReader,
) -> None:
"""An untouched channel holds no pattern at all, which reads as the empty cell it shows."""
- block = reader.read(_column(GeneratorName.NOISE))
+ block = reader.read(_column(ChannelName.NOISE))
- assert block.notes[_key(SubColumn.INSTRUMENT)] is None
+ assert block.notes[_key(SubColumn.VOICE)] is None
assert block.transposes[_key(SubColumn.TRANSPOSE)] is None
assert block.volumes[_key(SubColumn.VOLUME)] is None
@@ -113,11 +113,11 @@ def test_a_cut_cell_carries_the_cut(
logic: SequencerTrackerLogic,
reader: TrackerBlockReader,
) -> None:
- logic.cut_note(0, GeneratorName.PULSE1)
+ logic.cut_note(0, ChannelName.PULSE1)
- block = reader.read(_cell(0, GeneratorName.PULSE1, SubColumn.INSTRUMENT))
+ block = reader.read(_cell(0, ChannelName.PULSE1, SubColumn.VOICE))
- assert block.notes[_key(SubColumn.INSTRUMENT)] == NoteOff()
+ assert block.notes[_key(SubColumn.VOICE)] == NoteOff()
def test_a_zero_transpose_carries_as_the_value_it_is(
self,
@@ -125,9 +125,9 @@ def test_a_zero_transpose_carries_as_the_value_it_is(
reader: TrackerBlockReader,
) -> None:
"""An explicit zero resets the channel's transpose, so it is a value and not an absence."""
- logic.set_cell_subcolumn(0, GeneratorName.PULSE2, transpose=0)
+ logic.set_cell_subcolumn(0, ChannelName.PULSE2, transpose=0)
- block = reader.read(_cell(0, GeneratorName.PULSE2, SubColumn.TRANSPOSE))
+ block = reader.read(_cell(0, ChannelName.PULSE2, SubColumn.TRANSPOSE))
assert block.transposes[_key(SubColumn.TRANSPOSE)] == 0
@@ -138,9 +138,9 @@ def test_rows_past_the_pattern_read_empty(
) -> None:
"""A region reaching past the rows a pattern holds takes emptiness from beyond its end."""
logic.set_rows_per_pattern(2)
- logic.set_cell_subcolumn(0, GeneratorName.PULSE1, volume=4)
+ logic.set_cell_subcolumn(0, ChannelName.PULSE1, volume=4)
- block = reader.read(_column(GeneratorName.PULSE1, last_row=3))
+ block = reader.read(_column(ChannelName.PULSE1, last_row=3))
assert block.volumes[_key(SubColumn.VOLUME)] == 4
assert block.volumes[_key(SubColumn.VOLUME, 2)] is None
@@ -157,7 +157,7 @@ def test_a_value_every_governed_channel_shares_carries_over(
reader: TrackerBlockReader,
) -> None:
sample = controller.add_sample(
- sample_reconstruction([GeneratorName.PULSE1, GeneratorName.TRIANGLE]),
+ sample_reconstruction([ChannelName.PULSE1, ChannelName.TRIANGLE]),
name="lead",
)
logic.place_note(0, None, sample.id)
@@ -165,7 +165,7 @@ def test_a_value_every_governed_channel_shares_carries_over(
block = reader.read(_column(None))
- assert block.notes[_key(SubColumn.INSTRUMENT)] == sample.id
+ assert block.notes[_key(SubColumn.VOICE)] == sample.id
assert block.transposes[_key(SubColumn.TRANSPOSE)] == 7
def test_a_note_carries_as_the_sample_it_names(
@@ -176,14 +176,14 @@ def test_a_note_carries_as_the_sample_it_names(
) -> None:
"""The channels hold instruments of their own, and the block keeps the sample they share."""
sample = controller.add_sample(
- sample_reconstruction([GeneratorName.PULSE1, GeneratorName.PULSE2]),
+ sample_reconstruction([ChannelName.PULSE1, ChannelName.PULSE2]),
name="chord",
)
logic.place_note(0, None, sample.id)
- block = reader.read(_cell(0, None, SubColumn.INSTRUMENT))
+ block = reader.read(_cell(0, None, SubColumn.VOICE))
- assert block.notes[_key(SubColumn.INSTRUMENT)] == sample.id
+ assert block.notes[_key(SubColumn.VOICE)] == sample.id
def test_a_column_its_channels_disagree_over_leaves_its_key_out(
self,
@@ -191,7 +191,7 @@ def test_a_column_its_channels_disagree_over_leaves_its_key_out(
reader: TrackerBlockReader,
) -> None:
"""No sample governs the row, so the column spans every channel and only one holds a value."""
- logic.set_cell_subcolumn(0, GeneratorName.PULSE1, transpose=5)
+ logic.set_cell_subcolumn(0, ChannelName.PULSE1, transpose=5)
block = reader.read(_cell(0, None, SubColumn.TRANSPOSE))
@@ -202,11 +202,11 @@ def test_a_half_cut_row_leaves_its_note_out(
logic: SequencerTrackerLogic,
reader: TrackerBlockReader,
) -> None:
- logic.cut_note(0, GeneratorName.PULSE1)
+ logic.cut_note(0, ChannelName.PULSE1)
- block = reader.read(_cell(0, None, SubColumn.INSTRUMENT))
+ block = reader.read(_cell(0, None, SubColumn.VOICE))
- assert _key(SubColumn.INSTRUMENT) not in block.notes
+ assert _key(SubColumn.VOICE) not in block.notes
def test_a_wholly_cut_row_carries_the_cut(
self,
@@ -215,9 +215,9 @@ def test_a_wholly_cut_row_carries_the_cut(
) -> None:
logic.cut_note(0, None)
- block = reader.read(_cell(0, None, SubColumn.INSTRUMENT))
+ block = reader.read(_cell(0, None, SubColumn.VOICE))
- assert block.notes[_key(SubColumn.INSTRUMENT)] == NoteOff()
+ assert block.notes[_key(SubColumn.VOICE)] == NoteOff()
def test_an_untouched_row_carries_its_emptiness(
self,
@@ -226,7 +226,7 @@ def test_an_untouched_row_carries_its_emptiness(
"""Every channel is equally empty, which is a reading they agree on."""
block = reader.read(_column(None))
- assert block.notes[_key(SubColumn.INSTRUMENT)] is None
+ assert block.notes[_key(SubColumn.VOICE)] is None
assert block.transposes[_key(SubColumn.TRANSPOSE)] is None
assert block.volumes[_key(SubColumn.VOLUME)] is None
@@ -240,18 +240,18 @@ def test_a_mixed_edge_column_leaves_only_itself_out(
reader: TrackerBlockReader,
) -> None:
"""The last slot reads as nothing, and the cells beside it keep the offsets they stand at."""
- logic.set_cell_subcolumn(0, GeneratorName.PULSE1, volume=2)
+ logic.set_cell_subcolumn(0, ChannelName.PULSE1, volume=2)
block = reader.read(
TrackerRegion(
first_row=0,
last_row=0,
- first_slot=_slot(None, SubColumn.INSTRUMENT),
+ first_slot=_slot(None, SubColumn.VOICE),
last_slot=_slot(None, SubColumn.VOLUME),
)
)
- assert set(block.notes) == {_key(SubColumn.INSTRUMENT)}
+ assert set(block.notes) == {_key(SubColumn.VOICE)}
assert set(block.transposes) == {_key(SubColumn.TRANSPOSE)}
assert _key(SubColumn.VOLUME) not in block.volumes
@@ -268,8 +268,8 @@ def test_the_offsets_are_measured_from_the_column_the_block_begins_in(
TrackerRegion(
first_row=0,
last_row=0,
- first_slot=_slot(GeneratorName.PULSE2, SubColumn.TRANSPOSE),
- last_slot=_slot(GeneratorName.TRIANGLE, SubColumn.INSTRUMENT),
+ first_slot=_slot(ChannelName.PULSE2, SubColumn.TRANSPOSE),
+ last_slot=_slot(ChannelName.TRIANGLE, SubColumn.VOICE),
)
)
diff --git a/tests/unit/sampletones_application/logic/sequencer/tracker/test_tracker.py b/tests/unit/sampletones_application/logic/sequencer/tracker/test_tracker.py
index bee5646f1..a876576a2 100644
--- a/tests/unit/sampletones_application/logic/sequencer/tracker/test_tracker.py
+++ b/tests/unit/sampletones_application/logic/sequencer/tracker/test_tracker.py
@@ -2,13 +2,16 @@
from sampletones_application.logic.project.manager import ProjectManager
from sampletones_application.logic.sequencer.tracker import SequencerTrackerLogic
from sampletones_application.view_model.sequencer.subcolumn import SubColumn
-from sampletones_core.constants.enums import GeneratorName
+from sampletones_application.view_model.sequencer.voices import VoiceKind
+from sampletones_core.constants.enums import ChannelName
from sampletones_core.constants.general import MAX_TRANSPOSE, MAX_VOLUME
-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.voices.creation import new_instrument
+from sampletones_core.project.voices.note_off import NoteOff
+from sampletones_core.project.voices.note_on import NoteOn
+from sampletones_core.utils.display import NOTE_OFF, display_id
from sampletones_shared.constants.symbols import MIXED
-from tests.suite.sequencer import sample_reconstruction
+from tests.suite.sequencer import UNKNOWN_SAMPLE_ID, sample_reconstruction
def _controller() -> ProjectController:
@@ -17,25 +20,25 @@ def _controller() -> ProjectController:
def _row(
controller: ProjectController,
- generator: GeneratorName,
+ channel: ChannelName,
row_index: int = 0,
) -> Row:
song = controller.project.song
- pattern_index = song.order[0][generator]
- return song[generator].get_row(pattern_index, row_index)
+ pattern_index = song.order[0][channel]
+ return song[channel].get_row(pattern_index, row_index)
-def _place_instrument(
+def _place_voice(
controller: ProjectController,
- generator: GeneratorName,
- sample_id: str,
+ channel: ChannelName,
+ voice_id: str,
) -> None:
- pattern_index = controller.project.song.order[0][generator]
+ pattern_index = controller.project.song.order[0][channel]
controller.set_row(
- generator,
+ channel,
pattern_index,
0,
- command=Instrument(sample_id=sample_id, generator_name=generator),
+ command=NoteOn(voice_id=voice_id),
)
@@ -43,13 +46,13 @@ class TestClearCell:
def test_a_channel_cell_clears_only_that_channel(self) -> None:
controller = _controller()
logic = SequencerTrackerLogic(controller)
- logic.set_row(GeneratorName.PULSE1, 0, transpose=5)
- logic.set_row(GeneratorName.PULSE2, 0, transpose=7)
+ logic.set_row(ChannelName.PULSE1, 0, transpose=5)
+ logic.set_row(ChannelName.PULSE2, 0, transpose=7)
- logic.clear_cell(0, GeneratorName.PULSE1)
+ logic.clear_cell(0, ChannelName.PULSE1)
- assert _row(controller, GeneratorName.PULSE1).transpose is None
- assert _row(controller, GeneratorName.PULSE2).transpose == 7
+ assert _row(controller, ChannelName.PULSE1).transpose is None
+ assert _row(controller, ChannelName.PULSE2).transpose == 7
def test_the_sample_column_clears_every_channel(self) -> None:
controller = _controller()
@@ -58,55 +61,55 @@ def test_the_sample_column_clears_every_channel(self) -> None:
logic.clear_cell(0, None)
- for generator in GeneratorName.items():
- assert _row(controller, generator).transpose is None
+ for channel in ChannelName.items():
+ assert _row(controller, channel).transpose is None
class TestClearCellSubcolumn:
def test_a_channel_cell_clears_one_subcolumn_of_its_own(self) -> None:
controller = _controller()
logic = SequencerTrackerLogic(controller)
- logic.set_row(GeneratorName.PULSE1, 0, transpose=5, volume=10)
+ logic.set_row(ChannelName.PULSE1, 0, transpose=5, volume=10)
- logic.clear_cell_subcolumn(0, GeneratorName.PULSE1, SubColumn.TRANSPOSE)
+ logic.clear_cell_subcolumn(0, ChannelName.PULSE1, SubColumn.TRANSPOSE)
- row = _row(controller, GeneratorName.PULSE1)
+ row = _row(controller, ChannelName.PULSE1)
assert row.transpose is None
assert row.volume == 10
- def test_the_sample_column_clears_instruments_from_every_channel(self) -> None:
+ def test_the_sample_column_clears_the_voice_from_every_channel(self) -> None:
controller = _controller()
logic = SequencerTrackerLogic(controller)
sample = controller.add_sample(
- sample_reconstruction([GeneratorName.PULSE1, GeneratorName.TRIANGLE]),
+ sample_reconstruction([ChannelName.PULSE1, ChannelName.TRIANGLE]),
name="lead",
)
- logic.set_sample_instrument(0, sample.id)
- logic.set_note_off(GeneratorName.NOISE, 0)
+ logic.set_row_sample(0, sample.id)
+ logic.set_note_off(ChannelName.NOISE, 0)
- logic.clear_cell_subcolumn(0, None, SubColumn.INSTRUMENT)
+ logic.clear_cell_subcolumn(0, None, SubColumn.VOICE)
- for generator in GeneratorName.items():
- assert _row(controller, generator).command is None
+ for channel in ChannelName.items():
+ assert _row(controller, channel).command is None
def test_the_sample_column_clears_transpose_from_the_sample_channels(self) -> None:
controller = _controller()
logic = SequencerTrackerLogic(controller)
sample = controller.add_sample(
- sample_reconstruction([GeneratorName.PULSE1, GeneratorName.TRIANGLE]),
+ sample_reconstruction([ChannelName.PULSE1, ChannelName.TRIANGLE]),
name="lead",
)
- logic.set_sample_instrument(0, sample.id)
- for generator in GeneratorName.items():
- logic.set_row(generator, 0, transpose=5)
+ logic.set_row_sample(0, sample.id)
+ for channel in ChannelName.items():
+ logic.set_row(channel, 0, transpose=5)
logic.clear_cell_subcolumn(0, None, SubColumn.TRANSPOSE)
- for generator in (GeneratorName.PULSE1, GeneratorName.TRIANGLE):
- assert _row(controller, generator).transpose is None
+ for channel in (ChannelName.PULSE1, ChannelName.TRIANGLE):
+ assert _row(controller, channel).transpose is None
- for generator in (GeneratorName.PULSE2, GeneratorName.NOISE):
- assert _row(controller, generator).transpose == 5
+ for channel in (ChannelName.PULSE2, ChannelName.NOISE):
+ assert _row(controller, channel).transpose == 5
class TestWriteCell:
@@ -114,34 +117,33 @@ def test_a_sample_in_the_sample_column_spreads_over_its_channels(self) -> None:
controller = _controller()
logic = SequencerTrackerLogic(controller)
sample = controller.add_sample(
- sample_reconstruction([GeneratorName.PULSE1, GeneratorName.TRIANGLE]),
+ sample_reconstruction([ChannelName.PULSE1, ChannelName.TRIANGLE]),
name="lead",
)
logic.write_cell(0, None, sample.id, None, None)
- for generator in (GeneratorName.PULSE1, GeneratorName.TRIANGLE):
- assert isinstance(_row(controller, generator).command, Instrument)
+ for channel in (ChannelName.PULSE1, ChannelName.TRIANGLE):
+ assert isinstance(_row(controller, channel).command, NoteOn)
- for generator in (GeneratorName.PULSE2, GeneratorName.NOISE):
- assert _row(controller, generator).command is None
+ for channel in (ChannelName.PULSE2, ChannelName.NOISE):
+ assert _row(controller, channel).command is None
- def test_a_sample_in_a_channel_cell_is_named_for_that_channel(self) -> None:
- """A cell re-targets the sample onto its own channel, whichever channels the sample covers."""
+ def test_a_sample_in_a_channel_cell_lands_on_that_channel(self) -> None:
+ """A cell writes the voice into its own channel's pattern, whichever channels the sample covers."""
controller = _controller()
logic = SequencerTrackerLogic(controller)
sample = controller.add_sample(
- sample_reconstruction([GeneratorName.PULSE1]),
+ sample_reconstruction([ChannelName.PULSE1]),
name="lead",
)
- logic.write_cell(0, GeneratorName.NOISE, sample.id, None, None)
+ logic.write_cell(0, ChannelName.NOISE, sample.id, None, None)
- command = _row(controller, GeneratorName.NOISE).command
- assert isinstance(command, Instrument)
- assert command.sample_id == sample.id
- assert command.generator_name == GeneratorName.NOISE
- assert _row(controller, GeneratorName.PULSE1).command is None
+ command = _row(controller, ChannelName.NOISE).command
+ assert isinstance(command, NoteOn)
+ assert command.voice_id == sample.id
+ assert _row(controller, ChannelName.PULSE1).command is None
def test_a_transpose_in_the_sample_column_reaches_every_channel(self) -> None:
controller = _controller()
@@ -149,17 +151,17 @@ def test_a_transpose_in_the_sample_column_reaches_every_channel(self) -> None:
logic.write_cell(0, None, None, 5, None)
- for generator in GeneratorName.items():
- assert _row(controller, generator).transpose == 5
+ for channel in ChannelName.items():
+ assert _row(controller, channel).transpose == 5
def test_a_volume_in_a_channel_cell_leaves_the_rest_of_the_cell_standing(self) -> None:
controller = _controller()
logic = SequencerTrackerLogic(controller)
- logic.set_row(GeneratorName.PULSE1, 0, transpose=5)
+ logic.set_row(ChannelName.PULSE1, 0, transpose=5)
- logic.write_cell(0, GeneratorName.PULSE1, None, None, 10)
+ logic.write_cell(0, ChannelName.PULSE1, None, None, 10)
- row = _row(controller, GeneratorName.PULSE1)
+ row = _row(controller, ChannelName.PULSE1)
assert row.transpose == 5
assert row.volume == 10
@@ -170,9 +172,9 @@ def test_an_edit_carrying_no_value_leaves_the_frame_alone(self) -> None:
controller.append_frame()
logic.select_frame(1)
- logic.write_cell(0, GeneratorName.PULSE1, None, None, None)
+ logic.write_cell(0, ChannelName.PULSE1, None, None, None)
- assert controller.project.song.order[1][GeneratorName.PULSE1] is None
+ assert controller.project.song.order[1][ChannelName.PULSE1] is None
class TestCutNote:
@@ -180,10 +182,10 @@ def test_a_channel_cell_cuts_that_channel(self) -> None:
controller = _controller()
logic = SequencerTrackerLogic(controller)
- logic.cut_note(0, GeneratorName.PULSE1)
+ logic.cut_note(0, ChannelName.PULSE1)
- assert isinstance(_row(controller, GeneratorName.PULSE1).command, NoteOff)
- assert _row(controller, GeneratorName.PULSE2).command is None
+ assert isinstance(_row(controller, ChannelName.PULSE1).command, NoteOff)
+ assert _row(controller, ChannelName.PULSE2).command is None
def test_the_sample_column_cuts_every_channel(self) -> None:
controller = _controller()
@@ -191,8 +193,8 @@ def test_the_sample_column_cuts_every_channel(self) -> None:
logic.cut_note(0, None)
- for generator in GeneratorName.items():
- assert isinstance(_row(controller, generator).command, NoteOff)
+ for channel in ChannelName.items():
+ assert isinstance(_row(controller, channel).command, NoteOff)
class TestFrameRowCount:
@@ -222,9 +224,9 @@ class TestRowAccess:
def test_reads_the_stored_row(self) -> None:
controller = _controller()
logic = SequencerTrackerLogic(controller)
- logic.set_row(GeneratorName.PULSE1, 0, transpose=5)
+ logic.set_row(ChannelName.PULSE1, 0, transpose=5)
- row = logic.row(GeneratorName.PULSE1, 0)
+ row = logic.row(ChannelName.PULSE1, 0)
assert row is not None
assert row.transpose == 5
@@ -235,33 +237,33 @@ def test_a_channel_without_a_pattern_has_no_row(self) -> None:
controller.append_frame()
logic.select_frame(1)
- assert logic.row(GeneratorName.PULSE1, 0) is None
+ assert logic.row(ChannelName.PULSE1, 0) is None
-class TestReferencedGenerators:
+class TestReferencedChannels:
def test_one_placement_reports_the_samples_whole_span(self) -> None:
controller = _controller()
logic = SequencerTrackerLogic(controller)
sample = controller.add_sample(
- sample_reconstruction([GeneratorName.PULSE1, GeneratorName.TRIANGLE]),
+ sample_reconstruction([ChannelName.PULSE1, ChannelName.TRIANGLE]),
name="lead",
)
- _place_instrument(controller, GeneratorName.PULSE1, sample.id)
+ _place_voice(controller, ChannelName.PULSE1, sample.id)
- assert logic.referenced_generators(0) == frozenset(
+ assert logic.referenced_channels(0) == frozenset(
{
- GeneratorName.PULSE1,
- GeneratorName.TRIANGLE,
+ ChannelName.PULSE1,
+ ChannelName.TRIANGLE,
}
)
def test_a_row_naming_no_sample_references_no_channel(self) -> None:
controller = _controller()
logic = SequencerTrackerLogic(controller)
- logic.set_note_off(GeneratorName.PULSE1, 0)
+ logic.set_note_off(ChannelName.PULSE1, 0)
- assert logic.referenced_generators(0) == frozenset()
- assert logic.relevant_generators(0) == GeneratorName.items()
+ assert logic.referenced_channels(0) == frozenset()
+ assert logic.relevant_channels(0) == ChannelName.items()
class TestSetNoteOff:
@@ -269,10 +271,10 @@ def test_set_note_off_writes_note_off_command(self) -> None:
controller = _controller()
logic = SequencerTrackerLogic(controller)
- logic.set_note_off(GeneratorName.PULSE1, 0)
+ logic.set_note_off(ChannelName.PULSE1, 0)
assert isinstance(
- _row(controller, GeneratorName.PULSE1).command,
+ _row(controller, ChannelName.PULSE1).command,
NoteOff,
)
@@ -282,8 +284,8 @@ def test_set_note_off_all_generators_cuts_every_channel(self) -> None:
logic.set_note_off_all_generators(0)
- for generator in GeneratorName.items():
- assert isinstance(_row(controller, generator).command, NoteOff)
+ for channel in ChannelName.items():
+ assert isinstance(_row(controller, channel).command, NoteOff)
class TestSetSampleInstrument:
@@ -291,48 +293,44 @@ def test_fills_only_used_generators(self) -> None:
controller = _controller()
logic = SequencerTrackerLogic(controller)
sample = controller.add_sample(
- sample_reconstruction([GeneratorName.PULSE1, GeneratorName.TRIANGLE]),
+ sample_reconstruction([ChannelName.PULSE1, ChannelName.TRIANGLE]),
name="lead",
)
- logic.set_sample_instrument(0, sample.id)
+ logic.set_row_sample(0, sample.id)
- for generator in (GeneratorName.PULSE1, GeneratorName.TRIANGLE):
- command = _row(controller, generator).command
- assert isinstance(command, Instrument)
- assert command.sample_id == sample.id
- assert command.generator_name == generator
+ for channel in (ChannelName.PULSE1, ChannelName.TRIANGLE):
+ command = _row(controller, channel).command
+ assert isinstance(command, NoteOn)
+ assert command.voice_id == sample.id
- for generator in (GeneratorName.PULSE2, GeneratorName.NOISE):
- assert _row(controller, generator).command is None
+ for channel in (ChannelName.PULSE2, ChannelName.NOISE):
+ assert _row(controller, channel).command is None
def test_clears_channels_the_new_sample_does_not_use(self) -> None:
controller = _controller()
logic = SequencerTrackerLogic(controller)
stale = controller.add_sample(
- sample_reconstruction([GeneratorName.PULSE2]),
+ sample_reconstruction([ChannelName.PULSE2]),
name="bass",
)
- pattern_index = controller.project.song.order[0][GeneratorName.PULSE2]
+ pattern_index = controller.project.song.order[0][ChannelName.PULSE2]
controller.set_row(
- GeneratorName.PULSE2,
+ ChannelName.PULSE2,
pattern_index,
0,
- command=Instrument(
- sample_id=stale.id,
- generator_name=GeneratorName.PULSE2,
- ),
+ command=NoteOn(voice_id=stale.id),
volume=15,
)
lead = controller.add_sample(
- sample_reconstruction([GeneratorName.PULSE1]),
+ sample_reconstruction([ChannelName.PULSE1]),
name="lead",
)
- logic.set_sample_instrument(0, lead.id)
+ logic.set_row_sample(0, lead.id)
- assert _row(controller, GeneratorName.PULSE1).command is not None
- cleared = _row(controller, GeneratorName.PULSE2)
+ assert _row(controller, ChannelName.PULSE1).command is not None
+ cleared = _row(controller, ChannelName.PULSE2)
assert cleared.command is None
assert cleared.volume is None
@@ -340,48 +338,48 @@ def test_none_sample_clears_the_whole_row(self) -> None:
controller = _controller()
logic = SequencerTrackerLogic(controller)
sample = controller.add_sample(
- sample_reconstruction([GeneratorName.PULSE1]),
+ sample_reconstruction([ChannelName.PULSE1]),
name="lead",
)
- logic.set_sample_instrument(0, sample.id)
+ logic.set_row_sample(0, sample.id)
- logic.set_sample_instrument(0, None)
+ logic.set_row_sample(0, None)
- for generator in GeneratorName.items():
- assert _row(controller, generator).command is None
+ for channel in ChannelName.items():
+ assert _row(controller, channel).command is None
class TestSampleSubcolumn:
- def test_synchronises_across_relevant_channels_even_without_instrument(
+ def test_synchronizes_across_relevant_channels_even_without_instrument(
self,
) -> None:
controller = _controller()
logic = SequencerTrackerLogic(controller)
sample = controller.add_sample(
- sample_reconstruction([GeneratorName.PULSE1, GeneratorName.TRIANGLE]),
+ sample_reconstruction([ChannelName.PULSE1, ChannelName.TRIANGLE]),
name="lead",
)
- _place_instrument(controller, GeneratorName.PULSE1, sample.id)
+ _place_voice(controller, ChannelName.PULSE1, sample.id)
logic.set_sample_subcolumn(0, transpose=5)
logic.set_sample_subcolumn(0, volume=10)
- carrier = _row(controller, GeneratorName.PULSE1)
+ carrier = _row(controller, ChannelName.PULSE1)
assert carrier.command is not None
assert carrier.transpose == 5
assert carrier.volume == 10
- synced = _row(controller, GeneratorName.TRIANGLE)
+ synced = _row(controller, ChannelName.TRIANGLE)
assert synced.command is None
assert synced.transpose == 5
assert synced.volume == 10
- for generator in (GeneratorName.PULSE2, GeneratorName.NOISE):
- row = _row(controller, generator)
+ for channel in (ChannelName.PULSE2, ChannelName.NOISE):
+ row = _row(controller, channel)
assert row.transpose is None
assert row.volume is None
- def test_synchronises_across_all_channels_when_no_sample_is_referenced(
+ def test_synchronizes_across_all_channels_when_no_sample_is_referenced(
self,
) -> None:
controller = _controller()
@@ -389,8 +387,8 @@ def test_synchronises_across_all_channels_when_no_sample_is_referenced(
logic.set_sample_subcolumn(0, transpose=5, volume=10)
- for generator in GeneratorName.items():
- row = _row(controller, generator)
+ for channel in ChannelName.items():
+ row = _row(controller, channel)
assert row.command is None
assert row.transpose == 5
assert row.volume == 10
@@ -399,17 +397,17 @@ def test_clear_removes_one_subcolumn_across_relevant_channels(self) -> None:
controller = _controller()
logic = SequencerTrackerLogic(controller)
sample = controller.add_sample(
- sample_reconstruction([GeneratorName.PULSE1, GeneratorName.TRIANGLE]),
+ sample_reconstruction([ChannelName.PULSE1, ChannelName.TRIANGLE]),
name="lead",
)
- logic.set_sample_instrument(0, sample.id)
+ logic.set_row_sample(0, sample.id)
logic.set_sample_subcolumn(0, transpose=5)
logic.set_sample_subcolumn(0, volume=10)
logic.clear_sample_subcolumn(0, transpose=True)
- for generator in (GeneratorName.PULSE1, GeneratorName.TRIANGLE):
- row = _row(controller, generator)
+ for channel in (ChannelName.PULSE1, ChannelName.TRIANGLE):
+ row = _row(controller, channel)
assert row.transpose is None
assert row.volume == 10
assert row.command is not None
@@ -420,38 +418,38 @@ def test_first_nudge_writes_the_delta_from_zero(self) -> None:
controller = _controller()
logic = SequencerTrackerLogic(controller)
- logic.adjust_transpose(GeneratorName.PULSE1, 0, 1)
+ logic.adjust_transpose(ChannelName.PULSE1, 0, 1)
- assert _row(controller, GeneratorName.PULSE1).transpose == 1
+ assert _row(controller, ChannelName.PULSE1).transpose == 1
def test_repeated_nudges_accumulate(self) -> None:
controller = _controller()
logic = SequencerTrackerLogic(controller)
- logic.adjust_transpose(GeneratorName.PULSE1, 0, 1)
- logic.adjust_transpose(GeneratorName.PULSE1, 0, 12)
+ logic.adjust_transpose(ChannelName.PULSE1, 0, 1)
+ logic.adjust_transpose(ChannelName.PULSE1, 0, 12)
- assert _row(controller, GeneratorName.PULSE1).transpose == 13
+ assert _row(controller, ChannelName.PULSE1).transpose == 13
def test_clamps_to_max_transpose(self) -> None:
controller = _controller()
logic = SequencerTrackerLogic(controller)
- logic.set_row(GeneratorName.PULSE1, 0, transpose=MAX_TRANSPOSE)
+ logic.set_row(ChannelName.PULSE1, 0, transpose=MAX_TRANSPOSE)
- logic.adjust_transpose(GeneratorName.PULSE1, 0, 12)
+ logic.adjust_transpose(ChannelName.PULSE1, 0, 12)
- assert _row(controller, GeneratorName.PULSE1).transpose == MAX_TRANSPOSE
+ assert _row(controller, ChannelName.PULSE1).transpose == MAX_TRANSPOSE
- def test_preserves_instrument_and_volume(self) -> None:
+ def test_preserves_the_voice_and_the_volume(self) -> None:
controller = _controller()
logic = SequencerTrackerLogic(controller)
- sample = controller.add_sample(sample_reconstruction([GeneratorName.PULSE1]), name="lead")
- _place_instrument(controller, GeneratorName.PULSE1, sample.id)
- logic.adjust_volume(GeneratorName.PULSE1, 0, -1)
+ sample = controller.add_sample(sample_reconstruction([ChannelName.PULSE1]), name="lead")
+ _place_voice(controller, ChannelName.PULSE1, sample.id)
+ logic.adjust_volume(ChannelName.PULSE1, 0, -1)
- logic.adjust_transpose(GeneratorName.PULSE1, 0, 2)
+ logic.adjust_transpose(ChannelName.PULSE1, 0, 2)
- row = _row(controller, GeneratorName.PULSE1)
+ row = _row(controller, ChannelName.PULSE1)
assert row.command is not None
assert row.transpose == 2
assert row.volume == MAX_VOLUME - 1
@@ -462,26 +460,26 @@ def test_unset_volume_steps_down_from_full(self) -> None:
controller = _controller()
logic = SequencerTrackerLogic(controller)
- logic.adjust_volume(GeneratorName.PULSE1, 0, -1)
+ logic.adjust_volume(ChannelName.PULSE1, 0, -1)
- assert _row(controller, GeneratorName.PULSE1).volume == MAX_VOLUME - 1
+ assert _row(controller, ChannelName.PULSE1).volume == MAX_VOLUME - 1
def test_unset_volume_up_stays_full(self) -> None:
controller = _controller()
logic = SequencerTrackerLogic(controller)
- logic.adjust_volume(GeneratorName.PULSE1, 0, 1)
+ logic.adjust_volume(ChannelName.PULSE1, 0, 1)
- assert _row(controller, GeneratorName.PULSE1).volume == MAX_VOLUME
+ assert _row(controller, ChannelName.PULSE1).volume == MAX_VOLUME
def test_clamps_to_zero(self) -> None:
controller = _controller()
logic = SequencerTrackerLogic(controller)
- logic.set_row(GeneratorName.PULSE1, 0, volume=1)
+ logic.set_row(ChannelName.PULSE1, 0, volume=1)
- logic.adjust_volume(GeneratorName.PULSE1, 0, -4)
+ logic.adjust_volume(ChannelName.PULSE1, 0, -4)
- assert _row(controller, GeneratorName.PULSE1).volume == 0
+ assert _row(controller, ChannelName.PULSE1).volume == 0
class TestBuildTrackerAggregation:
@@ -489,57 +487,57 @@ def test_single_channel_of_a_multi_channel_sample_reads_mixed(self) -> None:
controller = _controller()
logic = SequencerTrackerLogic(controller)
sample = controller.add_sample(
- sample_reconstruction([GeneratorName.PULSE1, GeneratorName.TRIANGLE]),
+ sample_reconstruction([ChannelName.PULSE1, ChannelName.TRIANGLE]),
name="lead",
)
- _place_instrument(controller, GeneratorName.PULSE1, sample.id)
+ _place_voice(controller, ChannelName.PULSE1, sample.id)
row = logic.build_grid().rows[0]
- assert row.sample_instrument == MIXED
+ assert row.sample == MIXED
def test_full_placement_reads_as_the_sample(self) -> None:
controller = _controller()
logic = SequencerTrackerLogic(controller)
sample = controller.add_sample(
- sample_reconstruction([GeneratorName.PULSE1, GeneratorName.TRIANGLE]),
+ sample_reconstruction([ChannelName.PULSE1, ChannelName.TRIANGLE]),
name="lead",
)
- logic.set_sample_instrument(0, sample.id)
+ logic.set_row_sample(0, sample.id)
row = logic.build_grid().rows[0]
- assert row.sample_instrument == row.cells[GeneratorName.PULSE1].instrument
- assert row.sample_instrument != MIXED
+ assert row.sample == row.cells[ChannelName.PULSE1].voice
+ assert row.sample != MIXED
def test_diverging_transpose_renders_as_mixed(self) -> None:
controller = _controller()
logic = SequencerTrackerLogic(controller)
sample = controller.add_sample(
- sample_reconstruction([GeneratorName.PULSE1, GeneratorName.TRIANGLE]),
+ sample_reconstruction([ChannelName.PULSE1, ChannelName.TRIANGLE]),
name="lead",
)
- logic.set_sample_instrument(0, sample.id)
- logic.set_row(GeneratorName.PULSE1, 0, transpose=5)
+ logic.set_row_sample(0, sample.id)
+ logic.set_row(ChannelName.PULSE1, 0, transpose=5)
row = logic.build_grid().rows[0]
- assert row.sample_transpose == MIXED
+ assert row.transpose == MIXED
def test_shared_transpose_is_reflected_in_the_sample_column(self) -> None:
controller = _controller()
logic = SequencerTrackerLogic(controller)
sample = controller.add_sample(
- sample_reconstruction([GeneratorName.PULSE1, GeneratorName.TRIANGLE]),
+ sample_reconstruction([ChannelName.PULSE1, ChannelName.TRIANGLE]),
name="lead",
)
- logic.set_sample_instrument(0, sample.id)
+ logic.set_row_sample(0, sample.id)
logic.set_sample_subcolumn(0, transpose=5)
row = logic.build_grid().rows[0]
- assert row.sample_transpose == row.cells[GeneratorName.PULSE1].transpose
- assert row.sample_transpose != MIXED
+ assert row.transpose == row.cells[ChannelName.PULSE1].transpose
+ assert row.transpose != MIXED
class TestEmptyFrameAutoCreate:
@@ -552,13 +550,13 @@ def test_editing_an_empty_slot_creates_and_assigns_a_pattern(self) -> None:
self._append_empty_frame(controller)
logic.select_frame(1)
- logic.set_row(GeneratorName.PULSE1, 0, transpose=5)
+ logic.set_row(ChannelName.PULSE1, 0, transpose=5)
song = controller.project.song
- new_index = song.order[1][GeneratorName.PULSE1]
+ new_index = song.order[1][ChannelName.PULSE1]
assert new_index is not None
- assert song[GeneratorName.PULSE1].get_row(new_index, 0).transpose == 5
- assert song.order[1][GeneratorName.PULSE2] is None
+ assert song[ChannelName.PULSE1].get_row(new_index, 0).transpose == 5
+ assert song.order[1][ChannelName.PULSE2] is None
def test_empty_frame_still_shows_editable_rows(self) -> None:
controller = _controller()
@@ -569,3 +567,119 @@ def test_empty_frame_still_shows_editable_rows(self) -> None:
tracker = logic.build_grid()
assert len(tracker.rows) == controller.project.song.rows_per_pattern
+
+
+class TestWhatTheSampleColumnPlaces:
+ """The column spreads a voice over the channels it covers, which a recording states for itself."""
+
+ def test_a_sample_is_placed_across_the_channels_it_covers(self) -> None:
+ controller = _controller()
+ logic = SequencerTrackerLogic(controller)
+ sample = controller.add_sample(
+ sample_reconstruction([ChannelName.PULSE1, ChannelName.TRIANGLE]),
+ name="lead",
+ )
+
+ logic.place_note(0, None, sample.id)
+
+ for channel in (ChannelName.PULSE1, ChannelName.TRIANGLE):
+ assert isinstance(_row(controller, channel).command, NoteOn)
+
+ def test_an_instrument_leaves_the_row_as_it_stands(self) -> None:
+ controller = _controller()
+ logic = SequencerTrackerLogic(controller)
+ instrument = controller.add_instrument(new_instrument("lead"))
+
+ logic.place_note(0, None, instrument.id)
+
+ for channel in ChannelName.items():
+ assert _row(controller, channel).command is None
+
+ def test_an_instrument_lands_on_the_channel_column_that_names_it(self) -> None:
+ controller = _controller()
+ logic = SequencerTrackerLogic(controller)
+ instrument = controller.add_instrument(new_instrument("lead"))
+
+ logic.place_note(0, ChannelName.NOISE, instrument.id)
+
+ command = _row(controller, ChannelName.NOISE).command
+ assert isinstance(command, NoteOn)
+ assert command.voice_id == instrument.id
+
+ def test_the_column_answers_for_a_sample(self) -> None:
+ controller = _controller()
+ logic = SequencerTrackerLogic(controller)
+ sample = controller.add_sample(
+ sample_reconstruction([ChannelName.PULSE1]),
+ name="lead",
+ )
+
+ assert logic.places_in_sample_column(sample.id) is True
+
+ def test_the_column_stands_by_for_an_instrument(self) -> None:
+ controller = _controller()
+ logic = SequencerTrackerLogic(controller)
+ instrument = controller.add_instrument(new_instrument("lead"))
+
+ assert logic.places_in_sample_column(instrument.id) is False
+
+ def test_the_column_stands_by_for_a_voice_the_project_lost(self) -> None:
+ logic = SequencerTrackerLogic(_controller())
+
+ assert logic.places_in_sample_column(UNKNOWN_SAMPLE_ID) is False
+
+
+class TestWhatTheSampleColumnReads:
+ """The column summarizes what its own kind of voice put on the row."""
+
+ def test_an_instrument_alone_leaves_the_column_empty(self) -> None:
+ controller = _controller()
+ logic = SequencerTrackerLogic(controller)
+ instrument = controller.add_instrument(new_instrument("lead"))
+ _place_voice(controller, ChannelName.PULSE1, instrument.id)
+
+ row = logic.build_grid().rows[0]
+
+ assert row.sample == display_id(None)
+ assert row.sample_channels == frozenset()
+
+ def test_a_sample_beside_an_instrument_reads_as_that_sample(self) -> None:
+ controller = _controller()
+ logic = SequencerTrackerLogic(controller)
+ sample = controller.add_sample(
+ sample_reconstruction([ChannelName.PULSE1, ChannelName.TRIANGLE]),
+ name="lead",
+ )
+ instrument = controller.add_instrument(new_instrument("pad"))
+ logic.set_row_sample(0, sample.id)
+ _place_voice(controller, ChannelName.NOISE, instrument.id)
+
+ row = logic.build_grid().rows[0]
+
+ assert row.sample == row.cells[ChannelName.PULSE1].voice
+ assert row.sample != MIXED
+
+ def test_a_row_cut_on_every_channel_still_reads_as_a_cut(self) -> None:
+ controller = _controller()
+ logic = SequencerTrackerLogic(controller)
+
+ logic.cut_note(0, None)
+
+ assert logic.build_grid().rows[0].sample == NOTE_OFF
+
+ def test_a_cell_names_the_kind_of_the_voice_it_starts(self) -> None:
+ controller = _controller()
+ logic = SequencerTrackerLogic(controller)
+ sample = controller.add_sample(
+ sample_reconstruction([ChannelName.PULSE1]),
+ name="lead",
+ )
+ instrument = controller.add_instrument(new_instrument("pad"))
+ _place_voice(controller, ChannelName.PULSE1, sample.id)
+ _place_voice(controller, ChannelName.NOISE, instrument.id)
+
+ cells = logic.build_grid().rows[0].cells
+
+ assert cells[ChannelName.PULSE1].kind is VoiceKind.SAMPLE
+ assert cells[ChannelName.NOISE].kind is VoiceKind.INSTRUMENT
+ assert cells[ChannelName.TRIANGLE].kind is None
diff --git a/tests/unit/sampletones_application/logic/sequencer/tracker/test_write_note.py b/tests/unit/sampletones_application/logic/sequencer/tracker/test_write_note.py
new file mode 100644
index 000000000..c675740e1
--- /dev/null
+++ b/tests/unit/sampletones_application/logic/sequencer/tracker/test_write_note.py
@@ -0,0 +1,90 @@
+from typing import Final, Optional, Tuple
+
+from sampletones_application.logic.project.controller import ProjectController
+from sampletones_application.logic.project.manager import ProjectManager
+from sampletones_application.logic.sequencer.tracker import SequencerTrackerLogic
+from sampletones_core.constants.enums import ChannelName
+from sampletones_core.features.envelope import Envelope
+from sampletones_core.project.voices.creation import new_instrument
+from sampletones_core.project.voices.envelopes import InstrumentEnvelopes
+from sampletones_core.project.voices.instrument import Instrument
+from sampletones_core.project.voices.note_off import NoteOff
+from sampletones_core.project.voices.note_on import NoteOn
+from sampletones_core.project.voices.voice import voice_reference
+from tests.suite.sequencer import sample_reconstruction
+
+ROOT_PITCH: Final[int] = 60
+TYPED_PITCH: Final[int] = 67
+
+
+def _logic() -> Tuple[ProjectController, SequencerTrackerLogic]:
+ controller = ProjectController(ProjectManager())
+ return controller, SequencerTrackerLogic(controller)
+
+
+def _write(
+ controller: ProjectController,
+ channel: ChannelName,
+ row_index: int,
+ command: Optional[object],
+) -> None:
+ pattern_index = controller.project.song.order[0][channel]
+ controller.set_row(channel, pattern_index, row_index, command=command)
+
+
+def _transpose(logic: SequencerTrackerLogic, channel: ChannelName, row_index: int) -> Optional[int]:
+ row = logic.row(channel, row_index)
+ return row.transpose if row is not None else None
+
+
+class TestATypedNoteIsStatedAsAStepFromTheVoice:
+ def test_an_instrument_takes_the_step_that_reaches_the_note(self) -> None:
+ controller, logic = _logic()
+ instrument = controller.add_instrument(
+ Instrument(
+ name="lead",
+ envelopes=InstrumentEnvelopes(volume=Envelope(items=(15,))),
+ initial_pitch=ROOT_PITCH,
+ )
+ )
+ _write(controller, ChannelName.PULSE1, 0, NoteOn(voice_id=instrument.id))
+
+ logic.write_note(0, ChannelName.PULSE1, TYPED_PITCH)
+
+ assert _transpose(logic, ChannelName.PULSE1, 0) == TYPED_PITCH - ROOT_PITCH
+
+ def test_a_sample_takes_the_step_from_its_own_pitch(self) -> None:
+ controller, logic = _logic()
+ sample = controller.add_sample(sample_reconstruction([ChannelName.PULSE1]), name="bass")
+ _write(controller, ChannelName.PULSE1, 0, NoteOn(voice_id=sample.id))
+
+ logic.write_note(0, ChannelName.PULSE1, TYPED_PITCH)
+
+ expected = TYPED_PITCH - voice_reference(sample, ChannelName.PULSE1)
+ assert _transpose(logic, ChannelName.PULSE1, 0) == expected
+
+ def test_a_row_below_the_note_is_measured_against_the_voice_it_carries(self) -> None:
+ controller, logic = _logic()
+ instrument = controller.add_instrument(Instrument(name="lead", initial_pitch=ROOT_PITCH))
+ _write(controller, ChannelName.PULSE1, 0, NoteOn(voice_id=instrument.id))
+
+ logic.write_note(2, ChannelName.PULSE1, TYPED_PITCH)
+
+ assert _transpose(logic, ChannelName.PULSE1, 2) == TYPED_PITCH - ROOT_PITCH
+
+ def test_a_row_carrying_no_voice_is_left_as_it_stands(self) -> None:
+ _, logic = _logic()
+
+ logic.write_note(0, ChannelName.PULSE1, TYPED_PITCH)
+
+ assert _transpose(logic, ChannelName.PULSE1, 0) is None
+
+ def test_a_row_past_a_note_off_carries_no_voice(self) -> None:
+ controller, logic = _logic()
+ instrument = controller.add_instrument(new_instrument("lead"))
+ _write(controller, ChannelName.PULSE1, 0, NoteOn(voice_id=instrument.id))
+ _write(controller, ChannelName.PULSE1, 1, NoteOff())
+
+ logic.write_note(2, ChannelName.PULSE1, TYPED_PITCH)
+
+ assert _transpose(logic, ChannelName.PULSE1, 2) is None
diff --git a/tests/unit/sampletones_application/logic/sequencer/tracker/test_writer.py b/tests/unit/sampletones_application/logic/sequencer/tracker/test_writer.py
index 349dc4600..917c4666b 100644
--- a/tests/unit/sampletones_application/logic/sequencer/tracker/test_writer.py
+++ b/tests/unit/sampletones_application/logic/sequencer/tracker/test_writer.py
@@ -13,7 +13,8 @@
from sampletones_application.view_model.sequencer.region import TrackerCell, TrackerRegion
from sampletones_application.view_model.sequencer.slot import TrackerSlot
from sampletones_application.view_model.sequencer.subcolumn import SubColumn
-from sampletones_core.constants.enums import GeneratorName
+from sampletones_core.constants.enums import ChannelName
+from sampletones_core.project.voices.creation import new_instrument
from tests.suite.base import BaseTestSuite
from tests.suite.case import BaseRegularTestCase
from tests.suite.sequencer import (
@@ -28,43 +29,46 @@
EMPTY: Final[str] = ".. ... . | .. ... . | .. ... . | .. ... ."
LEAD: Final[str] = "00"
BASS: Final[str] = "01"
+PAD: Final[str] = "02"
@dataclass(frozen=True, kw_only=True)
class Grid:
- """A four-row frame with two samples, the state every paste case starts from."""
+ """A four-row frame with two samples and an instrument, the state every paste case starts from."""
controller: ProjectController
logic: SequencerTrackerLogic
writer: TrackerBlockWriter
- sample_ids: Tuple[str, ...]
+ voice_ids: Tuple[str, ...]
@pytest.fixture
def grid() -> Grid:
- """A frame short enough for a case to state whole, holding a sample over two channels and one
- over a third.
+ """A frame short enough for a case to state whole, holding a sample over two channels, one over
+ a third, and an instrument.
- Which channels a sample governs is what the sample column fans a write out over, so the pair
- covers both readings: a write that reaches some channels and clears the rest, and a note
- written into a channel its own reconstruction leaves out.
+ Which channels a sample governs is what the sample column fans a write out over, so the pair of
+ samples covers both readings: a write that reaches some channels and clears the rest, and a note
+ written into a channel its own reconstruction leaves out. The instrument beside them is what the
+ sample column stands by for, so a block carrying one states where it does and does not land.
"""
controller = ProjectController(ProjectManager())
logic = SequencerTrackerLogic(controller)
logic.set_rows_per_pattern(FRAME_ROWS)
lead = controller.add_sample(
- sample_reconstruction([GeneratorName.PULSE1, GeneratorName.PULSE2]),
+ sample_reconstruction([ChannelName.PULSE1, ChannelName.PULSE2]),
name="lead",
)
bass = controller.add_sample(
- sample_reconstruction([GeneratorName.TRIANGLE]),
+ sample_reconstruction([ChannelName.TRIANGLE]),
name="bass",
)
+ pad = controller.add_instrument(new_instrument("pad"))
return Grid(
controller=controller,
logic=logic,
writer=TrackerBlockWriter(logic),
- sample_ids=(lead.id, bass.id),
+ voice_ids=(lead.id, bass.id, pad.id),
)
@@ -89,7 +93,7 @@ class TestCase(BaseRegularTestCase):
label="a block keeps its own kinds wherever the cursor stands",
block=("+02 8",),
first_subcolumn=SubColumn.TRANSPOSE,
- origin=TrackerCell(row=1, generator=GeneratorName.PULSE2),
+ origin=TrackerCell(row=1, channel=ChannelName.PULSE2),
expected=(
EMPTY,
".. ... . | .. +02 8 | .. ... . | .. ... .",
@@ -101,8 +105,8 @@ class TestCase(BaseRegularTestCase):
label="a sample through the sample column reaches its channels and clears the rest",
frame=(".. ... . | .. ... . | .. ... . | .. ... 5",),
block=(LEAD,),
- first_subcolumn=SubColumn.INSTRUMENT,
- origin=TrackerCell(row=0, generator=None),
+ first_subcolumn=SubColumn.VOICE,
+ origin=TrackerCell(row=0, channel=None),
expected=(
"00 ... . | 00 ... . | .. ... . | .. ... .",
EMPTY,
@@ -110,11 +114,30 @@ class TestCase(BaseRegularTestCase):
EMPTY,
),
),
+ TestCase(
+ label="an instrument through the sample column is passed over",
+ block=(PAD,),
+ first_subcolumn=SubColumn.VOICE,
+ origin=TrackerCell(row=0, channel=None),
+ expected=(EMPTY, EMPTY, EMPTY, EMPTY),
+ ),
+ TestCase(
+ label="an instrument through a channel column lands on that channel",
+ block=(PAD,),
+ first_subcolumn=SubColumn.VOICE,
+ origin=TrackerCell(row=0, channel=ChannelName.NOISE),
+ expected=(
+ ".. ... . | .. ... . | .. ... . | 02 ... .",
+ EMPTY,
+ EMPTY,
+ EMPTY,
+ ),
+ ),
TestCase(
label="a channel beside the sample column overwrites what it settled",
block=(f"{LEAD} ... . | {BASS}",),
- first_subcolumn=SubColumn.INSTRUMENT,
- origin=TrackerCell(row=0, generator=None),
+ first_subcolumn=SubColumn.VOICE,
+ origin=TrackerCell(row=0, channel=None),
expected=(
"01 ... . | 00 ... . | .. ... . | .. ... .",
EMPTY,
@@ -125,8 +148,8 @@ class TestCase(BaseRegularTestCase):
TestCase(
label="a block read from the sample column writes one channel when written to one",
block=(LEAD,),
- first_subcolumn=SubColumn.INSTRUMENT,
- origin=TrackerCell(row=0, generator=GeneratorName.TRIANGLE),
+ first_subcolumn=SubColumn.VOICE,
+ origin=TrackerCell(row=0, channel=ChannelName.TRIANGLE),
expected=(
".. ... . | .. ... . | 00 ... . | .. ... .",
EMPTY,
@@ -135,11 +158,11 @@ class TestCase(BaseRegularTestCase):
),
),
TestCase(
- label="a mixed cell leaves its target as it stands while its neighbours clear theirs",
+ label="a mixed cell leaves its target as it stands while its neighbors clear theirs",
frame=("00 +03 7 | .. ... . | .. ... . | .. ... .",),
block=(".. ? .",),
- first_subcolumn=SubColumn.INSTRUMENT,
- origin=TrackerCell(row=0, generator=GeneratorName.PULSE1),
+ first_subcolumn=SubColumn.VOICE,
+ origin=TrackerCell(row=0, channel=ChannelName.PULSE1),
expected=(
".. +03 . | .. ... . | .. ... . | .. ... .",
EMPTY,
@@ -152,7 +175,7 @@ class TestCase(BaseRegularTestCase):
frame=(".. +03 . | .. +05 . | .. ... . | .. ... .",),
block=("+00 ? | ? ...",),
first_subcolumn=SubColumn.TRANSPOSE,
- origin=TrackerCell(row=0, generator=GeneratorName.PULSE1),
+ origin=TrackerCell(row=0, channel=ChannelName.PULSE1),
expected=(
".. +00 . | .. ... . | .. ... . | .. ... .",
EMPTY,
@@ -164,8 +187,8 @@ class TestCase(BaseRegularTestCase):
label="a cut through the sample column cuts every channel",
frame=("00 ... . | 00 ... . | .. ... . | .. ... .",),
block=("~~",),
- first_subcolumn=SubColumn.INSTRUMENT,
- origin=TrackerCell(row=0, generator=None),
+ first_subcolumn=SubColumn.VOICE,
+ origin=TrackerCell(row=0, channel=None),
expected=(
"~~ ... . | ~~ ... . | ~~ ... . | ~~ ... .",
EMPTY,
@@ -177,8 +200,8 @@ class TestCase(BaseRegularTestCase):
label="a note naming an absent sample writes nothing into a channel",
frame=("00 +02 5 | .. ... . | .. ... . | .. ... .",),
block=("!! ? ?",),
- first_subcolumn=SubColumn.INSTRUMENT,
- origin=TrackerCell(row=0, generator=GeneratorName.PULSE1),
+ first_subcolumn=SubColumn.VOICE,
+ origin=TrackerCell(row=0, channel=ChannelName.PULSE1),
expected=(
"00 +02 5 | .. ... . | .. ... . | .. ... .",
EMPTY,
@@ -190,8 +213,8 @@ class TestCase(BaseRegularTestCase):
label="a note naming an absent sample clears nothing through the sample column",
frame=("00 ... . | 00 ... . | .. ... . | .. ... 5",),
block=("!!",),
- first_subcolumn=SubColumn.INSTRUMENT,
- origin=TrackerCell(row=0, generator=None),
+ first_subcolumn=SubColumn.VOICE,
+ origin=TrackerCell(row=0, channel=None),
expected=(
"00 ... . | 00 ... . | .. ... . | .. ... 5",
EMPTY,
@@ -203,8 +226,8 @@ class TestCase(BaseRegularTestCase):
label="an empty instrument through the sample column clears every channel",
frame=("00 ... . | 00 ... . | .. ... . | ~~ ... .",),
block=("..",),
- first_subcolumn=SubColumn.INSTRUMENT,
- origin=TrackerCell(row=0, generator=None),
+ first_subcolumn=SubColumn.VOICE,
+ origin=TrackerCell(row=0, channel=None),
expected=(EMPTY, EMPTY, EMPTY, EMPTY),
),
TestCase(
@@ -212,7 +235,7 @@ class TestCase(BaseRegularTestCase):
frame=(".. +02 . | .. +02 . | .. +02 . | .. +02 .",),
block=("...",),
first_subcolumn=SubColumn.TRANSPOSE,
- origin=TrackerCell(row=0, generator=None),
+ origin=TrackerCell(row=0, channel=None),
expected=(EMPTY, EMPTY, EMPTY, EMPTY),
),
TestCase(
@@ -220,7 +243,7 @@ class TestCase(BaseRegularTestCase):
frame=("00 ... . | 00 ... . | .. ... . | .. ... .",),
block=("+02",),
first_subcolumn=SubColumn.TRANSPOSE,
- origin=TrackerCell(row=0, generator=None),
+ origin=TrackerCell(row=0, channel=None),
expected=(
"00 +02 . | 00 +02 . | .. ... . | .. ... .",
EMPTY,
@@ -232,7 +255,7 @@ class TestCase(BaseRegularTestCase):
label="a silent volume writes zero rather than emptiness",
block=("0",),
first_subcolumn=SubColumn.VOLUME,
- origin=TrackerCell(row=0, generator=GeneratorName.PULSE1),
+ origin=TrackerCell(row=0, channel=ChannelName.PULSE1),
expected=(
".. ... 0 | .. ... . | .. ... . | .. ... .",
EMPTY,
@@ -244,7 +267,7 @@ class TestCase(BaseRegularTestCase):
label="rows past the frame's last are dropped rather than wrapped",
block=("+01", "+02", "+03"),
first_subcolumn=SubColumn.TRANSPOSE,
- origin=TrackerCell(row=2, generator=GeneratorName.PULSE1),
+ origin=TrackerCell(row=2, channel=ChannelName.PULSE1),
expected=(
EMPTY,
EMPTY,
@@ -255,8 +278,8 @@ class TestCase(BaseRegularTestCase):
TestCase(
label="slots past the last column are dropped rather than wrapped",
block=(f"{LEAD} ... . | {BASS}",),
- first_subcolumn=SubColumn.INSTRUMENT,
- origin=TrackerCell(row=0, generator=GeneratorName.NOISE),
+ first_subcolumn=SubColumn.VOICE,
+ origin=TrackerCell(row=0, channel=ChannelName.NOISE),
expected=(
".. ... . | .. ... . | .. ... . | 00 ... .",
EMPTY,
@@ -268,8 +291,8 @@ class TestCase(BaseRegularTestCase):
label="a wholly mixed block leaves the frame as it stands",
frame=("00 +02 5 | .. ... . | .. ... . | .. ... .",),
block=("? ? ?",),
- first_subcolumn=SubColumn.INSTRUMENT,
- origin=TrackerCell(row=0, generator=GeneratorName.PULSE1),
+ first_subcolumn=SubColumn.VOICE,
+ origin=TrackerCell(row=0, channel=ChannelName.PULSE1),
expected=(
"00 +02 5 | .. ... . | .. ... . | .. ... .",
EMPTY,
@@ -281,8 +304,8 @@ class TestCase(BaseRegularTestCase):
label="a wholly empty block empties what it covers",
frame=("00 +02 5 | 00 +02 5 | .. ... . | .. ... .",),
block=(".. ... .",),
- first_subcolumn=SubColumn.INSTRUMENT,
- origin=TrackerCell(row=0, generator=GeneratorName.PULSE1),
+ first_subcolumn=SubColumn.VOICE,
+ origin=TrackerCell(row=0, channel=ChannelName.PULSE1),
expected=(
".. ... . | 00 +02 5 | .. ... . | .. ... .",
EMPTY,
@@ -302,11 +325,11 @@ def test_the_frame_after_a_paste(
grid: Grid,
test_case: TestCase,
) -> None:
- fill_frame(grid.logic, test_case.frame, sample_ids=grid.sample_ids)
+ fill_frame(grid.logic, test_case.frame, voice_ids=grid.voice_ids)
block = parse_block(
test_case.block,
first_subcolumn=test_case.first_subcolumn,
- sample_ids=grid.sample_ids,
+ voice_ids=grid.voice_ids,
)
grid.writer.write(block, test_case.origin)
@@ -322,13 +345,13 @@ def test_a_single_cell_block_matches_the_edit_it_stands_for(self, grid: Grid) ->
block = parse_block(
("+02",),
first_subcolumn=SubColumn.TRANSPOSE,
- sample_ids=grid.sample_ids,
+ voice_ids=grid.voice_ids,
)
- grid.writer.write(block, TrackerCell(row=0, generator=GeneratorName.PULSE1))
+ grid.writer.write(block, TrackerCell(row=0, channel=ChannelName.PULSE1))
pasted = render_frame(grid.logic)
typed = _typed_grid()
- typed.set_cell_subcolumn(0, GeneratorName.PULSE1, transpose=2)
+ typed.set_cell_subcolumn(0, ChannelName.PULSE1, transpose=2)
assert pasted == render_frame(typed)
@@ -340,15 +363,15 @@ def test_a_region_empties_the_subcolumns_it_covers(self, grid: Grid) -> None:
fill_frame(
grid.logic,
("00 +02 5 | 00 +03 6 | .. ... . | .. ... .",),
- sample_ids=grid.sample_ids,
+ voice_ids=grid.voice_ids,
)
grid.writer.clear(
TrackerRegion(
first_row=0,
last_row=0,
- first_slot=TrackerSlot(GeneratorName.PULSE1, SubColumn.TRANSPOSE).flat_index,
- last_slot=TrackerSlot(GeneratorName.PULSE2, SubColumn.INSTRUMENT).flat_index,
+ first_slot=TrackerSlot(ChannelName.PULSE1, SubColumn.TRANSPOSE).flat_index,
+ last_slot=TrackerSlot(ChannelName.PULSE2, SubColumn.VOICE).flat_index,
)
)
@@ -358,7 +381,7 @@ def test_a_region_over_the_sample_column_empties_the_channels_it_governs(self, g
fill_frame(
grid.logic,
("00 +02 5 | 00 +02 5 | .. ... . | .. ... 5",),
- sample_ids=grid.sample_ids,
+ voice_ids=grid.voice_ids,
)
grid.writer.clear(
@@ -383,19 +406,19 @@ def test_a_block_written_back_at_its_origin_restores_the_frame(self, grid: Grid)
"00 +02 5 | 00 ... . | .. ... . | ~~ ... 3",
".. ... . | 01 +00 0 | .. +07 . | .. ... .",
),
- sample_ids=grid.sample_ids,
+ voice_ids=grid.voice_ids,
)
before = render_frame(grid.logic)
region = TrackerRegion(
first_row=0,
last_row=1,
- first_slot=TrackerSlot(GeneratorName.PULSE1, SubColumn.INSTRUMENT).flat_index,
- last_slot=TrackerSlot(GeneratorName.NOISE, SubColumn.VOLUME).flat_index,
+ first_slot=TrackerSlot(ChannelName.PULSE1, SubColumn.VOICE).flat_index,
+ last_slot=TrackerSlot(ChannelName.NOISE, SubColumn.VOLUME).flat_index,
)
block = TrackerBlockReader(grid.logic).read(region)
grid.writer.clear(region)
- grid.writer.write(block, TrackerCell(row=0, generator=GeneratorName.PULSE1))
+ grid.writer.write(block, TrackerCell(row=0, channel=ChannelName.PULSE1))
assert render_frame(grid.logic) == before
@@ -412,9 +435,9 @@ def test_a_frame_holding_no_pattern_gains_one_where_a_block_lands(self, grid: Gr
block = parse_block(
("+02",),
first_subcolumn=SubColumn.TRANSPOSE,
- sample_ids=grid.sample_ids,
+ voice_ids=grid.voice_ids,
)
- grid.writer.write(block, TrackerCell(row=0, generator=GeneratorName.PULSE2))
+ grid.writer.write(block, TrackerCell(row=0, channel=ChannelName.PULSE2))
assert render_slots(grid.controller, position) == ".. 01 .. .."
assert render_frame(grid.logic)[0] == ".. ... . | .. +02 . | .. ... . | .. ... ."
@@ -426,10 +449,10 @@ def test_a_wholly_mixed_block_leaves_a_frame_with_no_patterns_at_all(self, grid:
block = parse_block(
("? ? ?",),
- first_subcolumn=SubColumn.INSTRUMENT,
- sample_ids=grid.sample_ids,
+ first_subcolumn=SubColumn.VOICE,
+ voice_ids=grid.voice_ids,
)
- grid.writer.write(block, TrackerCell(row=0, generator=GeneratorName.PULSE2))
+ grid.writer.write(block, TrackerCell(row=0, channel=ChannelName.PULSE2))
assert render_slots(grid.controller, position) == ".. .. .. .."
diff --git a/tests/unit/sampletones_application/logic/shared/test_project_source.py b/tests/unit/sampletones_application/logic/shared/test_project_source.py
index 4d8b081ca..ddea9bf89 100644
--- a/tests/unit/sampletones_application/logic/shared/test_project_source.py
+++ b/tests/unit/sampletones_application/logic/shared/test_project_source.py
@@ -37,7 +37,7 @@ def test_reconstruction_audio_is_shared(
snapshot = snapshot_project(project_controller.project)
- assert snapshot.samples[sample.id].reconstruction is sample.reconstruction
+ assert snapshot.voices[sample.id].reconstruction is sample.reconstruction
class TestASnapshotIsASource(BaseTestSuite):
diff --git a/tests/unit/sampletones_application/services/export/test_result.py b/tests/unit/sampletones_application/services/export/test_result.py
index 715763e14..f4222ef4a 100644
--- a/tests/unit/sampletones_application/services/export/test_result.py
+++ b/tests/unit/sampletones_application/services/export/test_result.py
@@ -7,7 +7,7 @@
from sampletones_application.services.export.kind import ExportKind
from sampletones_application.services.export.success import ExportSuccess
from sampletones_core.exporters.truncation import EnvelopeTruncation
-from sampletones_core.trackers.format import TrackerFormat
+from sampletones_core.exports.format import ExportFormat
class TestExportSuccess:
@@ -16,22 +16,22 @@ def test_stores_kind_and_filepath(self) -> None:
success = ExportSuccess(
kind=ExportKind.WAV,
filepath=filepath,
- tracker_format=None,
+ export_format=None,
truncation=None,
)
assert success.kind == ExportKind.WAV
assert success.filepath == filepath
- assert success.tracker_format is None
+ assert success.export_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,
+ export_format=ExportFormat.BITPHASE,
truncation=None,
)
- assert success.tracker_format == TrackerFormat.BITPHASE
+ assert success.export_format == ExportFormat.BITPHASE
def test_stores_the_truncation(self) -> None:
truncation = EnvelopeTruncation(
@@ -42,7 +42,7 @@ def test_stores_the_truncation(self) -> None:
success = ExportSuccess(
kind=ExportKind.INSTRUMENT,
filepath=Path("/x"),
- tracker_format=TrackerFormat.FAMITRACKER,
+ export_format=ExportFormat.FAMITRACKER,
truncation=truncation,
)
assert success.truncation == truncation
@@ -51,7 +51,7 @@ def test_frozen(self) -> None:
success = ExportSuccess(
kind=ExportKind.WAV,
filepath=Path("/x"),
- tracker_format=None,
+ export_format=None,
truncation=None,
)
with pytest.raises(FrozenInstanceError):
@@ -62,23 +62,23 @@ def test_equality(self) -> None:
assert ExportSuccess(
kind=ExportKind.WAV,
filepath=path,
- tracker_format=None,
+ export_format=None,
truncation=None,
) == ExportSuccess(
kind=ExportKind.WAV,
filepath=path,
- tracker_format=None,
+ export_format=None,
truncation=None,
)
assert ExportSuccess(
kind=ExportKind.WAV,
filepath=path,
- tracker_format=None,
+ export_format=None,
truncation=None,
) != ExportSuccess(
kind=ExportKind.INSTRUMENT,
filepath=path,
- tracker_format=None,
+ export_format=None,
truncation=None,
)
@@ -87,12 +87,12 @@ def test_the_tracker_format_separates_two_otherwise_equal_results(self) -> None:
assert ExportSuccess(
kind=ExportKind.INSTRUMENT,
filepath=path,
- tracker_format=TrackerFormat.FAMITRACKER,
+ export_format=ExportFormat.FAMITRACKER,
truncation=None,
) != ExportSuccess(
kind=ExportKind.INSTRUMENT,
filepath=path,
- tracker_format=TrackerFormat.BITPHASE,
+ export_format=ExportFormat.BITPHASE,
truncation=None,
)
@@ -102,17 +102,17 @@ def test_stores_kind_and_exception(self) -> None:
exception = OSError("disk full")
error = ExportError(
kind=ExportKind.INSTRUMENT,
- tracker_format=TrackerFormat.FAMITRACKER,
+ export_format=ExportFormat.FAMITRACKER,
exception=exception,
)
assert error.kind == ExportKind.INSTRUMENT
- assert error.tracker_format == TrackerFormat.FAMITRACKER
+ assert error.export_format == ExportFormat.FAMITRACKER
assert error.exception is exception
def test_frozen(self) -> None:
error = ExportError(
kind=ExportKind.WAV,
- tracker_format=None,
+ export_format=None,
exception=OSError(),
)
with pytest.raises(FrozenInstanceError):
@@ -122,12 +122,12 @@ def test_eq_false_same_exception_instances_differ(self) -> None:
exception = OSError("same")
error_a = ExportError(
kind=ExportKind.WAV,
- tracker_format=None,
+ export_format=None,
exception=exception,
)
error_b = ExportError(
kind=ExportKind.WAV,
- tracker_format=None,
+ export_format=None,
exception=exception,
)
assert error_a != error_b
@@ -135,7 +135,7 @@ def test_eq_false_same_exception_instances_differ(self) -> None:
def test_same_instance_equals_itself(self) -> None:
error = ExportError(
kind=ExportKind.WAV,
- tracker_format=None,
+ export_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 664aaba72..25be24ed6 100644
--- a/tests/unit/sampletones_application/services/export/test_service.py
+++ b/tests/unit/sampletones_application/services/export/test_service.py
@@ -1,5 +1,5 @@
from pathlib import Path
-from typing import Any, Final, List, Optional, Tuple
+from typing import Any, Callable, Final, List, Optional, Tuple
from unittest.mock import patch
import numpy as np
@@ -9,20 +9,40 @@
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.constants.enums import GeneratorName
+from sampletones_application.services.result import (
+ ServiceCanceled,
+ ServiceProgress,
+ ServiceStarted,
+)
+from sampletones_core.constants.enums import ChannelName
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 (
+from sampletones_core.exports.artifact import ExportArtifact
+from sampletones_core.exports.format import ExportFormat
+from sampletones_core.exports.progress import (
+ ExportReporter,
+ announce,
+)
+from sampletones_core.exports.request import (
InstrumentExport,
ProjectExport,
SampleExport,
)
-from sampletones_core.trackers.scope import ExportScope
+from sampletones_core.exports.scope import ExportScope
+from sampletones_core.exports.stage import ExportStage
+from sampletones_core.features.envelope import Envelope
+from sampletones_core.project.project import Project
+from sampletones_shared.music import Tuning
+from sampletones_shared.utils.progress import silent_reporter
NES_FREQUENCY: Final[int] = 60
+NOTHING_WRITTEN: Final[int] = 0
+ONE_FILE: Final[int] = 1
+
+
+def outcome(results: List[Any]) -> Any:
+ """The result a run finished on, which follows whatever it said while it ran."""
+ return results[-1]
class StubBackend:
@@ -40,10 +60,11 @@ def __init__(
self.truncation = truncation
self.exception = exception
self.calls: List[Tuple[str, Path, Any]] = []
+ self.on_write: Optional[Callable[[], None]] = None
@property
- def tracker_format(self) -> TrackerFormat:
- return TrackerFormat.FAMITRACKER
+ def export_format(self) -> ExportFormat:
+ return ExportFormat.FAMITRACKER
@property
def supported_scopes(self) -> frozenset:
@@ -56,50 +77,59 @@ def write_instrument(
self,
destination: Path,
request: InstrumentExport,
+ report: ExportReporter = silent_reporter,
) -> ExportArtifact:
- return self._write("instrument", destination, request)
+ return self._write("instrument", destination, request, report)
def write_sample(
self,
destination: Path,
request: SampleExport,
+ report: ExportReporter = silent_reporter,
) -> ExportArtifact:
- return self._write("sample", destination, request)
+ return self._write("sample", destination, request, report)
def write_project(
self,
destination: Path,
request: ProjectExport,
+ report: ExportReporter = silent_reporter,
) -> ExportArtifact:
- return self._write("project", destination, request)
+ return self._write("project", destination, request, report)
def _write(
self,
scope: str,
destination: Path,
request: Any,
+ report: ExportReporter,
) -> ExportArtifact:
self.calls.append((scope, destination, request))
+ if self.on_write is not None:
+ self.on_write()
+
+ announce(report, ExportStage.WRITING, NOTHING_WRITTEN, ONE_FILE)
if self.exception is not None:
raise self.exception
+ announce(report, ExportStage.WRITING, ONE_FILE, ONE_FILE)
return ExportArtifact(paths=(destination,), truncation=self.truncation)
def build_instrument(name: str = "Lead") -> InstrumentExport:
return InstrumentExport(
name=name,
- generator=GeneratorName.PULSE1,
+ channel=ChannelName.PULSE1,
features=Features(
initial_pitch=60,
- volume=np.full(8, 15, dtype=int),
- arpeggio=np.zeros(8, dtype=int),
+ volume=Envelope(items=(15,) * 8),
+ arpeggio=Envelope(items=(0,) * 8),
pitch=None,
hi_pitch=None,
duty_cycle=None,
),
- loop=False,
nes_frequency=NES_FREQUENCY,
+ tuning=Tuning(),
)
@@ -108,6 +138,7 @@ def build_sample(count: int = 2) -> SampleExport:
name="Kick",
instruments=tuple(build_instrument(f"Kick {index}") for index in range(count)),
nes_frequency=NES_FREQUENCY,
+ tuning=Tuning(),
)
@@ -131,8 +162,7 @@ def test_success_emits_export_success(self, service, tmp_path) -> None:
with patch("sampletones_application.services.export.service.write_wave"):
export_service.export_wav(filepath, 44100, np.zeros(100))
- assert len(results) == 1
- result = results[0]
+ result = outcome(results)
assert isinstance(result, ExportSuccess)
assert result.kind == ExportKind.WAV
assert result.filepath == filepath
@@ -162,8 +192,7 @@ def test_error_emits_export_error(self, service, tmp_path) -> None:
):
export_service.export_wav(filepath, 44100, np.zeros(100))
- assert len(results) == 1
- result = results[0]
+ result = outcome(results)
assert isinstance(result, ExportError)
assert result.kind == ExportKind.WAV
assert result.exception is exception
@@ -191,8 +220,7 @@ def test_success_emits_export_success(self, service, tmp_path) -> None:
build_instrument(),
)
- assert len(results) == 1
- result = results[0]
+ result = outcome(results)
assert isinstance(result, ExportSuccess)
assert result.kind == ExportKind.INSTRUMENT
assert result.filepath == filepath
@@ -221,8 +249,7 @@ def test_error_emits_export_error(self, service, tmp_path) -> None:
build_instrument(),
)
- assert len(results) == 1
- result = results[0]
+ result = outcome(results)
assert isinstance(result, ExportError)
assert result.kind == ExportKind.INSTRUMENT
assert result.exception is exception
@@ -249,8 +276,7 @@ def test_success_emits_export_success_with_the_destination(
export_service.export_sample(tmp_path, StubBackend(), build_sample())
- assert len(results) == 1
- result = results[0]
+ result = outcome(results)
assert isinstance(result, ExportSuccess)
assert result.kind == ExportKind.SAMPLE
assert result.filepath == tmp_path
@@ -278,8 +304,7 @@ def test_error_emits_export_error(self, service, tmp_path) -> None:
build_sample(),
)
- assert len(results) == 1
- result = results[0]
+ result = outcome(results)
assert isinstance(result, ExportError)
assert result.kind == ExportKind.SAMPLE
assert result.exception is exception
@@ -293,9 +318,8 @@ def test_a_sample_with_no_slices_emits_success(
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
+ assert isinstance(outcome(results), ExportSuccess)
+ assert outcome(results).kind == ExportKind.SAMPLE
class TestExportProject:
@@ -305,8 +329,7 @@ def test_success_emits_export_success(self, service, tmp_path) -> None:
export_service.export_project(filepath, StubBackend(), build_project())
- assert len(results) == 1
- result = results[0]
+ result = outcome(results)
assert isinstance(result, ExportSuccess)
assert result.kind == ExportKind.PROJECT
assert result.filepath == filepath
@@ -335,8 +358,7 @@ def test_error_emits_export_error(self, service, tmp_path) -> None:
build_project(),
)
- assert len(results) == 1
- result = results[0]
+ result = outcome(results)
assert isinstance(result, ExportError)
assert result.kind == ExportKind.PROJECT
assert result.exception is exception
@@ -356,7 +378,7 @@ def test_a_tracker_export_names_the_format_it_was_written_in(
build_instrument(),
)
- assert results[0].tracker_format == TrackerFormat.FAMITRACKER
+ assert outcome(results).export_format == ExportFormat.FAMITRACKER
def test_a_failed_tracker_export_names_the_format_it_was_written_in(
self,
@@ -371,7 +393,7 @@ def test_a_failed_tracker_export_names_the_format_it_was_written_in(
build_sample(),
)
- assert results[0].tracker_format == TrackerFormat.FAMITRACKER
+ assert outcome(results).export_format == ExportFormat.FAMITRACKER
def test_a_wav_export_names_no_format(self, service, tmp_path) -> None:
export_service, results = service
@@ -383,7 +405,7 @@ def test_a_wav_export_names_no_format(self, service, tmp_path) -> None:
np.zeros(100),
)
- assert results[0].tracker_format is None
+ assert outcome(results).export_format is None
class TestExportTruncationReporting:
@@ -400,7 +422,7 @@ def test_a_complete_instrument_reports_no_truncation(
build_instrument(),
)
- assert results[0].truncation is None
+ assert outcome(results).truncation is None
def test_a_shortened_instrument_carries_the_backend_report(
self,
@@ -420,7 +442,7 @@ def test_a_shortened_instrument_carries_the_backend_report(
build_instrument(),
)
- assert results[0].truncation == truncation
+ assert outcome(results).truncation == truncation
def test_a_shortened_sample_carries_the_backend_report(
self,
@@ -440,7 +462,7 @@ def test_a_shortened_sample_carries_the_backend_report(
build_sample(3),
)
- assert results[0].truncation == truncation
+ assert outcome(results).truncation == truncation
def test_a_wav_export_reports_no_truncation(
self,
@@ -456,7 +478,7 @@ def test_a_wav_export_reports_no_truncation(
np.zeros(100),
)
- assert results[0].truncation is None
+ assert outcome(results).truncation is None
class TestExportServiceConcurrency:
@@ -507,3 +529,121 @@ def on_result(result: Any) -> None:
)
assert call_count == 0
+
+
+class CancellingBackend:
+ """Withdraws the run from inside it, the way a user pressing Cancel does."""
+
+ def __init__(self, service: ExportService) -> None:
+ self._service = service
+ self.stages: List[ExportStage] = []
+
+ @property
+ def export_format(self) -> ExportFormat:
+ return ExportFormat.NSF
+
+ @property
+ def supported_scopes(self) -> frozenset:
+ return frozenset(ExportScope)
+
+ def extension(self, scope: ExportScope) -> str:
+ return ".nsf"
+
+ def write_instrument(
+ self,
+ destination: Path,
+ request: InstrumentExport,
+ report: ExportReporter = silent_reporter,
+ ) -> ExportArtifact:
+ announce(report, ExportStage.WALKING, NOTHING_WRITTEN, None)
+ self.stages.append(ExportStage.WALKING)
+ self._service.cancel()
+ announce(report, ExportStage.COMPRESSING, ONE_FILE, None)
+ self.stages.append(ExportStage.COMPRESSING)
+ return ExportArtifact(paths=(destination,), truncation=None)
+
+ def write_sample(
+ self,
+ destination: Path,
+ request: SampleExport,
+ report: ExportReporter = silent_reporter,
+ ) -> ExportArtifact:
+ raise NotImplementedError
+
+ def write_project(
+ self,
+ destination: Path,
+ request: ProjectExport,
+ report: ExportReporter = silent_reporter,
+ ) -> ExportArtifact:
+ raise NotImplementedError
+
+
+class TestWhatARunSaysAboutItself:
+ """The export speaks the vocabulary every other service speaks."""
+
+ def test_a_run_opens_with_a_start(self, service, tmp_path) -> None:
+ export_service, results = service
+ export_service.export_instrument(tmp_path / "instrument.fti", StubBackend(), build_instrument())
+ assert isinstance(results[0], ServiceStarted)
+
+ def test_the_stage_a_format_names_reaches_the_subscriber(self, service, tmp_path) -> None:
+ export_service, results = service
+ export_service.export_instrument(tmp_path / "instrument.fti", StubBackend(), build_instrument())
+ reported = [result for result in results if isinstance(result, ServiceProgress)]
+ assert reported and all(result.current_item == ExportStage.WRITING for result in reported)
+
+ def test_a_stage_that_lands_on_its_total_is_reported(self, service, tmp_path) -> None:
+ export_service, results = service
+ export_service.export_instrument(tmp_path / "instrument.fti", StubBackend(), build_instrument())
+ reported = [result for result in results if isinstance(result, ServiceProgress)]
+ assert reported[-1].completed == ONE_FILE
+
+
+class TestWithdrawingARun:
+ """A canceled export answers with a cancellation rather than a failure."""
+
+ def test_a_canceled_run_ends_canceled(self, service, tmp_path) -> None:
+ export_service, results = service
+ export_service.export_instrument(
+ tmp_path / "instrument.nsf",
+ CancellingBackend(export_service),
+ build_instrument(),
+ )
+ assert isinstance(outcome(results), ServiceCanceled)
+
+ def test_a_canceled_run_reports_no_failure(self, service, tmp_path) -> None:
+ export_service, results = service
+ export_service.export_instrument(
+ tmp_path / "instrument.nsf",
+ CancellingBackend(export_service),
+ build_instrument(),
+ )
+ assert not any(isinstance(result, (ExportSuccess, ExportError)) for result in results)
+
+ def test_the_format_stops_where_it_was_told(self, service, tmp_path) -> None:
+ export_service, _ = service
+ backend = CancellingBackend(export_service)
+ export_service.export_instrument(tmp_path / "instrument.nsf", backend, build_instrument())
+ assert backend.stages == [ExportStage.WALKING]
+
+
+class TestOneExportAtATime:
+ """An export claims the application, so a second request is declined rather than queued."""
+
+ def test_a_finished_run_leaves_the_service_idle(self, service, tmp_path) -> None:
+ export_service, _ = service
+ export_service.export_instrument(tmp_path / "instrument.fti", StubBackend(), build_instrument())
+ assert export_service.is_running() is False
+
+ def test_a_request_arriving_mid_run_is_declined(self, service, tmp_path) -> None:
+ export_service, _ = service
+ backend = StubBackend()
+ second = StubBackend()
+
+ def start_another() -> None:
+ export_service.export_instrument(tmp_path / "second.fti", second, build_instrument())
+
+ backend.on_write = start_another
+ export_service.export_instrument(tmp_path / "instrument.fti", backend, build_instrument())
+ assert second.calls == []
diff --git a/tests/unit/sampletones_application/services/render/conftest.py b/tests/unit/sampletones_application/services/render/conftest.py
index a67b0d581..bc1edf8f3 100644
--- a/tests/unit/sampletones_application/services/render/conftest.py
+++ b/tests/unit/sampletones_application/services/render/conftest.py
@@ -21,7 +21,7 @@ def wave_spec(sample_rate: int = SAMPLE_RATE) -> AudioOutputSpec:
class FakeSynthesizer:
"""A kernel that renders a fixed number of identical rows, standing in for a song.
- Each row is a constant level, so a normalising pass has a peak to find and a written file
+ Each row is a constant level, so a normalizing pass has a peak to find and a written file
can be checked sample by sample without modelling a generator.
"""
diff --git a/tests/unit/sampletones_application/services/render/test_service.py b/tests/unit/sampletones_application/services/render/test_service.py
index 2a1ce82c3..f40183fff 100644
--- a/tests/unit/sampletones_application/services/render/test_service.py
+++ b/tests/unit/sampletones_application/services/render/test_service.py
@@ -8,7 +8,7 @@
from sampletones_application.services.render.result import RenderResult, RenderStage
from sampletones_application.services.render.service import SongRenderService
from sampletones_application.services.result import (
- ServiceCancelled,
+ ServiceCanceled,
ServiceError,
ServiceProgress,
ServiceStarted,
@@ -116,7 +116,7 @@ def test_the_encoding_pass_climbs_to_the_total(self, tmp_path: Path) -> None:
class TestNormalizing(BaseTestSuite):
- """Normalising scales the whole render by what its loudest sample turned out to be."""
+ """Normalizing scales the whole render by what its loudest sample turned out to be."""
def test_the_peak_reaches_full_scale(self, tmp_path: Path) -> None:
destination = tmp_path / "song.wav"
@@ -148,12 +148,12 @@ def test_the_spill_file_is_removed(self, tmp_path: Path) -> None:
class TestCancelling(BaseTestSuite):
- """A cancelled render reports itself cancelled and names no file."""
+ """A canceled render reports itself canceled and names no file."""
def _cancelling_synthesizer(self, service: SongRenderService) -> FakeSynthesizer:
return FakeSynthesizer(on_row=lambda rendered: service.cancel() if rendered == 4 else None)
- def test_a_cancelled_render_leaves_no_file(self, tmp_path: Path) -> None:
+ def test_a_canceled_render_leaves_no_file(self, tmp_path: Path) -> None:
destination = tmp_path / "song.wav"
service = SongRenderService()
service.start(
@@ -166,7 +166,7 @@ def test_a_cancelled_render_leaves_no_file(self, tmp_path: Path) -> None:
assert not destination.exists()
- def test_a_cancelled_render_reports_itself_cancelled(self, tmp_path: Path) -> None:
+ def test_a_canceled_render_reports_itself_canceled(self, tmp_path: Path) -> None:
service = SongRenderService()
results: List[RenderResult] = []
service.subscribe(results.append)
@@ -178,9 +178,9 @@ def test_a_cancelled_render_reports_itself_cancelled(self, tmp_path: Path) -> No
total_samples=TOTAL_SAMPLES,
)
- assert results[-1] == ServiceCancelled()
+ assert results[-1] == ServiceCanceled()
- def test_a_cancelled_normalized_render_leaves_no_spill(self, tmp_path: Path) -> None:
+ def test_a_canceled_normalized_render_leaves_no_spill(self, tmp_path: Path) -> None:
service = SongRenderService()
service.start(
synthesizer=self._cancelling_synthesizer(service),
diff --git a/tests/unit/sampletones_application/services/retune/test_retune.py b/tests/unit/sampletones_application/services/retune/test_retune.py
index 0c1d1759e..e30d59b63 100644
--- a/tests/unit/sampletones_application/services/retune/test_retune.py
+++ b/tests/unit/sampletones_application/services/retune/test_retune.py
@@ -21,7 +21,7 @@ def test_emits_one_success_per_target(self) -> None:
service._run([("a", first), ("b", second)], 60)
assert [type(result) for result in results] == [ServiceSuccess, ServiceSuccess]
- assert [result.value.sample_id for result in results] == ["a", "b"]
+ assert [result.value.voice_id for result in results] == ["a", "b"]
assert results[0].value.reconstruction is first.with_nes_frequency.return_value
def test_retunes_each_target_to_the_requested_rate(self) -> None:
@@ -46,7 +46,7 @@ def test_emits_service_error_when_a_retune_raises(self) -> None:
assert len(results) == 1
assert isinstance(results[0], ServiceError)
- def test_result_carries_the_sample_id_and_retuned_reconstruction(self) -> None:
+ def test_result_carries_the_voice_id_and_retuned_reconstruction(self) -> None:
service = SampleRetuneService()
results: List[Any] = []
service.subscribe(results.append)
@@ -54,7 +54,7 @@ def test_result_carries_the_sample_id_and_retuned_reconstruction(self) -> None:
service._run([("lead", _reconstruction(retuned))], 60)
- assert results[0].value == RetunedSample(sample_id="lead", reconstruction=retuned)
+ assert results[0].value == RetunedSample(voice_id="lead", reconstruction=retuned)
class TestSampleRetuneServiceStart:
diff --git a/tests/unit/sampletones_application/services/song_player/test_song_player.py b/tests/unit/sampletones_application/services/song_player/test_song_player.py
index 40db1bcbd..b92ef0065 100644
--- a/tests/unit/sampletones_application/services/song_player/test_song_player.py
+++ b/tests/unit/sampletones_application/services/song_player/test_song_player.py
@@ -4,16 +4,16 @@
import numpy as np
-from sampletones_application.services.song_player.player import (
- SongPlayerService,
- _RenderedRow,
-)
from sampletones_application.services.song_player.result import (
SongPlaybackError,
SongPlaybackStopped,
SongPlayerResult,
SongPositionUpdate,
)
+from sampletones_application.services.song_player.service import (
+ SongPlayerService,
+ _RenderedRow,
+)
from sampletones_core.project.song_position import SongPosition
SAMPLE_RATE: Final[int] = 44100
@@ -21,7 +21,7 @@
WAIT_TIMEOUT: Final[float] = 5.0
SHORT_JOIN_TIMEOUT: Final[float] = 0.05
WRITE_RELEASE_DELAY: Final[float] = 0.05
-JOIN_TIMEOUT_TARGET: Final[str] = "sampletones_application.services.song_player.player.STOP_JOIN_TIMEOUT"
+JOIN_TIMEOUT_TARGET: Final[str] = "sampletones_application.services.song_player.service.STOP_JOIN_TIMEOUT"
def _make_service(
diff --git a/tests/unit/sampletones_application/services/test_conversion.py b/tests/unit/sampletones_application/services/test_conversion.py
index d7580c142..403ff0a0e 100644
--- a/tests/unit/sampletones_application/services/test_conversion.py
+++ b/tests/unit/sampletones_application/services/test_conversion.py
@@ -5,9 +5,9 @@
import pytest
-from sampletones_application.services.conversion import ConversionService
+from sampletones_application.services.conversion.service import ConversionService
from sampletones_application.services.result import (
- ServiceCancelled,
+ ServiceCanceled,
ServiceError,
ServiceIntermediate,
ServiceProgress,
@@ -22,7 +22,7 @@
@pytest.fixture
def mock_converter_class() -> Iterator[MockConverterClass]:
- with patch("sampletones_application.services.conversion.ReconstructionConverter") as cls:
+ with patch("sampletones_application.services.conversion.service.ReconstructionConverter") as cls:
instance = MagicMock()
instance.is_running.return_value = False
instance.status = TaskStatus.COMPLETED
@@ -76,7 +76,7 @@ def test_start_wires_five_lifecycle_callbacks(
"on_progress",
"on_completed",
"on_error",
- "on_cancelled",
+ "on_canceled",
}
def test_start_while_running_does_not_create_second_converter(
@@ -122,7 +122,8 @@ def test_on_progress_running_emits_service_progress(
assert isinstance(result, ServiceProgress)
assert result.completed == 2
assert result.total == 5
- assert result.current_item == Path("/some/file.wav")
+ assert result.current_item is not None
+ assert result.current_item.source == Path("/some/file.wav")
def test_on_progress_cancelling_emits_service_progress(
self,
@@ -202,15 +203,15 @@ def test_on_error_emits_service_error(
assert isinstance(result, ServiceError)
assert result.exception is exception
- def test_on_cancelled_emits_service_cancelled(
+ def test_on_canceled_emits_service_canceled(
self,
service: Service,
) -> None:
_, _, callbacks, results = service
- callbacks["on_cancelled"]()
+ callbacks["on_canceled"]()
assert len(results) == 1
- assert isinstance(results[0], ServiceCancelled)
+ assert isinstance(results[0], ServiceCanceled)
def test_forward_library_progress_emits_service_intermediate(
self,
diff --git a/tests/unit/sampletones_application/services/test_progress.py b/tests/unit/sampletones_application/services/test_progress.py
new file mode 100644
index 000000000..d44b94f8e
--- /dev/null
+++ b/tests/unit/sampletones_application/services/test_progress.py
@@ -0,0 +1,83 @@
+from typing import Final, List
+
+from sampletones_application.services.progress import UNMEASURED, StageProgress
+from sampletones_application.services.render.result import RenderStage
+from sampletones_application.services.result import ServiceProgress
+from sampletones_core.exports.stage import ExportStage
+from sampletones_shared.utils.progress import PROGRESS_STEPS
+
+TOTAL_SAMPLES: Final[int] = PROGRESS_STEPS * 100
+STEP: Final[int] = TOTAL_SAMPLES // PROGRESS_STEPS
+NOTHING_COVERED: Final[int] = 0
+PROGRAM_AREA: Final[int] = 32429
+FIRST_SIZE: Final[int] = 12689
+SMALLER_SIZE: Final[int] = FIRST_SIZE - PROGRAM_AREA // PROGRESS_STEPS - 1
+
+
+class TestReportingAtABoundedRate:
+ """A stage reports on a fraction of what it is measured against, whatever its length."""
+
+ def test_a_step_reaches_the_subscriber(self) -> None:
+ reports: List[ServiceProgress[RenderStage]] = []
+ progress = StageProgress(RenderStage.SYNTHESIS, TOTAL_SAMPLES, emit=reports.append, estimates=True)
+ progress.advance(STEP)
+ assert reports[-1].completed == STEP
+
+ def test_a_move_short_of_a_step_is_held_back(self) -> None:
+ reports: List[ServiceProgress[RenderStage]] = []
+ progress = StageProgress(RenderStage.SYNTHESIS, TOTAL_SAMPLES, emit=reports.append, estimates=True)
+ progress.advance(STEP)
+ progress.advance(STEP + STEP - 1)
+ assert [report.completed for report in reports] == [STEP]
+
+ def test_a_stage_says_where_it_begins(self) -> None:
+ reports: List[ServiceProgress[RenderStage]] = []
+ progress = StageProgress(RenderStage.SYNTHESIS, TOTAL_SAMPLES, emit=reports.append, estimates=True)
+ progress.advance(NOTHING_COVERED)
+ assert [report.completed for report in reports] == [NOTHING_COVERED]
+
+ def test_a_stage_landing_on_its_total_is_always_reported(self) -> None:
+ reports: List[ServiceProgress[RenderStage]] = []
+ progress = StageProgress(RenderStage.SYNTHESIS, TOTAL_SAMPLES, emit=reports.append, estimates=True)
+ progress.advance(TOTAL_SAMPLES)
+ progress.advance(TOTAL_SAMPLES)
+ assert [report.completed for report in reports] == [TOTAL_SAMPLES, TOTAL_SAMPLES]
+
+ def test_the_stage_names_what_the_counts_are_in(self) -> None:
+ reports: List[ServiceProgress[RenderStage]] = []
+ progress = StageProgress(RenderStage.ENCODING, TOTAL_SAMPLES, emit=reports.append, estimates=True)
+ progress.advance(TOTAL_SAMPLES)
+ assert reports[-1].current_item == RenderStage.ENCODING
+
+
+class TestACountThatFalls:
+ """A compression's bytes fall as the dictionary earns its keep, and that is still a step."""
+
+ def test_a_fall_of_a_step_is_reported(self) -> None:
+ reports: List[ServiceProgress[ExportStage]] = []
+ progress = StageProgress(ExportStage.COMPRESSING, PROGRAM_AREA, emit=reports.append, estimates=False)
+ progress.advance(FIRST_SIZE)
+ progress.advance(SMALLER_SIZE)
+ assert [report.completed for report in reports] == [FIRST_SIZE, SMALLER_SIZE]
+
+
+class TestWhereAnEstimateStands:
+ """A remaining time is stated only where the count travels toward the total."""
+
+ def test_a_stage_travelling_to_its_total_estimates(self) -> None:
+ reports: List[ServiceProgress[RenderStage]] = []
+ progress = StageProgress(RenderStage.SYNTHESIS, TOTAL_SAMPLES, emit=reports.append, estimates=True)
+ progress.advance(TOTAL_SAMPLES)
+ assert reports[-1].eta_seconds is not None
+
+ def test_a_stage_measured_against_a_limit_states_none(self) -> None:
+ reports: List[ServiceProgress[ExportStage]] = []
+ progress = StageProgress(ExportStage.COMPRESSING, PROGRAM_AREA, emit=reports.append, estimates=False)
+ progress.advance(FIRST_SIZE)
+ assert reports[-1].eta_seconds is None
+
+ def test_a_stage_with_nothing_to_measure_against_states_none(self) -> None:
+ reports: List[ServiceProgress[ExportStage]] = []
+ progress = StageProgress(ExportStage.WALKING, UNMEASURED, emit=reports.append, estimates=True)
+ progress.advance(UNMEASURED)
+ assert reports[-1].eta_seconds is None
diff --git a/tests/unit/sampletones_application/services/test_regeneration.py b/tests/unit/sampletones_application/services/test_regeneration.py
index ede8ea039..485f6072a 100644
--- a/tests/unit/sampletones_application/services/test_regeneration.py
+++ b/tests/unit/sampletones_application/services/test_regeneration.py
@@ -6,14 +6,16 @@
import numpy as np
import pytest
-from sampletones_application.services.regeneration import RegenerationService
+from sampletones_application.services.regeneration.service import RegenerationService
from sampletones_application.services.result import (
- ServiceCancelled,
+ ServiceCanceled,
ServiceError,
ServiceSuccess,
)
-from sampletones_core.constants.enums import FeatureKey, GeneratorName
+from sampletones_core.constants.enums import ChannelName, FeatureKey
from sampletones_core.exporters import Features
+from sampletones_core.features import CHANNEL_GENERATOR_KIND, supported_features
+from sampletones_core.features.envelope import Envelope
from sampletones_core.reconstructions import Reconstruction
from tests.conftest import ReconstructionFactory
@@ -24,32 +26,16 @@
ResultCallback: TypeAlias = Callable[[Any], None]
-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``. The dimensions left to
- the channel are read the same way the real model reports them: those whose envelope is empty.
- """
-
- def __init__(self, initial_pitch: int) -> None:
- super().__init__()
- self.initial_pitch = initial_pitch
-
- @property
- def held_features(self) -> Tuple[FeatureKey, ...]:
- return tuple(key for key, value in self.items() if isinstance(value, np.ndarray) and value.size == 0)
-
- 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)
+def features() -> Features:
+ return Features(
+ initial_pitch=REFERENCE_PITCH,
+ volume=Envelope[int](items=(15, 0)),
+ arpeggio=Envelope[int](items=(0, 0)),
+ pitch=None,
+ hi_pitch=None,
+ duty_cycle=Envelope[int](items=(0, 0)),
+ )
@pytest.fixture
@@ -63,18 +49,18 @@ def synthesis_mocks() -> Iterator[SynthesisMocks]:
mock_exporter.get_generator_type.return_value = mock_generator_class
mock_exporter.from_features.return_value = [mock_instruction]
- generator_name = GeneratorName.PULSE1
+ channel_name = ChannelName.PULSE1
with patch(
- "sampletones_application.services.regeneration.GENERATOR_NAME_TO_EXPORTER_MAP",
- {generator_name: mock_exporter},
+ "sampletones_application.services.regeneration.service.CHANNEL_TO_EXPORTER_MAP",
+ {channel_name: mock_exporter},
):
yield SimpleNamespace(
exporter=mock_exporter,
generator_class=mock_generator_class,
generator=mock_generator,
instruction=mock_instruction,
- generator_name=generator_name,
+ channel_name=channel_name,
)
@@ -86,28 +72,27 @@ def reconstruction() -> MockReconstruction:
class TestRegenerationServiceStart:
- def test_start_when_not_cancelled_returns_true(
+ def test_start_when_not_canceled_returns_true(
self, synthesis_mocks: SynthesisMocks, reconstruction: MockReconstruction
) -> None:
service = RegenerationService()
result = service.start(
reconstruction,
- synthesis_mocks.generator_name,
- cast(Features, {}),
+ synthesis_mocks.channel_name,
FeatureKey.VOLUME,
- 1,
+ cast(Features, {}),
)
assert result is True
- def test_start_when_cancelled_returns_false(self) -> None:
+ def test_start_when_canceled_returns_false(self) -> None:
service = RegenerationService()
service.cancel()
- result = service.start(MagicMock(), MagicMock(), cast(Features, {}), MagicMock(), MagicMock())
+ result = service.start(MagicMock(), MagicMock(), FeatureKey.VOLUME, cast(Features, {}))
assert result is False
- def test_start_when_cancelled_does_not_emit(self) -> None:
+ def test_start_when_canceled_does_not_emit(self) -> None:
service = RegenerationService()
results: List[Any] = []
service.subscribe(results.append)
@@ -116,9 +101,8 @@ def test_start_when_cancelled_does_not_emit(self) -> None:
service.start(
MagicMock(),
MagicMock(),
- cast(Features, {}),
- MagicMock(),
MagicMock(),
+ cast(Features, {}),
)
assert results == []
@@ -134,20 +118,19 @@ def test_start_reports_a_submit_failure(self) -> None:
result = service.start(
MagicMock(),
MagicMock(),
- cast(Features, {}),
- MagicMock(),
MagicMock(),
+ cast(Features, {}),
)
assert result is False
- def test_cancel_sets_cancelled_flag(self) -> None:
+ def test_cancel_sets_canceled_flag(self) -> None:
service = RegenerationService()
- assert not service._cancelled
+ assert not service._canceled
service.cancel()
- assert service._cancelled
+ assert service._canceled
class TestRegenerationServiceIsRunning:
@@ -163,28 +146,27 @@ def test_is_running_delegates_to_the_executor(self) -> None:
class TestRegenerationServiceRun:
- def test_run_when_cancelled_emits_service_cancelled(self) -> None:
+ def test_run_when_canceled_emits_service_canceled(self) -> None:
service = RegenerationService()
results: List[Any] = []
service.subscribe(results.append)
- service._cancelled = True
+ service._canceled = True
service._run(
MagicMock(),
MagicMock(),
- cast(Features, {}),
- MagicMock(),
MagicMock(),
+ cast(Features, {}),
)
assert len(results) == 1
- assert isinstance(results[0], ServiceCancelled)
+ assert isinstance(results[0], ServiceCanceled)
def test_run_success_emits_service_success(
self,
synthesis_mocks: SynthesisMocks,
reconstruction: MockReconstruction,
- features: FakeFeatures,
+ features: Features,
) -> None:
service = RegenerationService()
results: List[Any] = []
@@ -192,10 +174,9 @@ def test_run_success_emits_service_success(
service._run(
reconstruction,
- synthesis_mocks.generator_name,
- cast(Features, features),
+ synthesis_mocks.channel_name,
FeatureKey.VOLUME,
- 1,
+ features,
)
assert len(results) == 1
@@ -203,56 +184,50 @@ def test_run_success_emits_service_success(
outcome = results[0].value
assert outcome.reconstruction is reconstruction.model_copy.return_value
assert outcome.reconstruction is not reconstruction
- assert outcome.generator_name is synthesis_mocks.generator_name
+ assert outcome.channel_name is synthesis_mocks.channel_name
assert outcome.feature_key is FeatureKey.VOLUME
- def test_run_updates_feature_before_synthesis(
+ def test_run_regenerates_from_the_envelopes_it_is_handed(
self,
synthesis_mocks: SynthesisMocks,
reconstruction: MockReconstruction,
- features: FakeFeatures,
+ features: Features,
) -> None:
+ """The caller writes the edit into the envelopes, so the service renders what it is given."""
service = RegenerationService()
- feature_key = FeatureKey.VOLUME
- new_value = 42
- service._run(
- reconstruction,
- synthesis_mocks.generator_name,
- cast(Features, features),
- feature_key,
- new_value,
- )
+ service._run(reconstruction, synthesis_mocks.channel_name, FeatureKey.VOLUME, features)
- assert features[feature_key] == new_value
+ _, _, _, initial_pitch, held = reconstruction.model_copy.return_value.update_channel_data.call_args.args
+ assert initial_pitch == features.initial_pitch
+ assert held == features.held_features
def test_run_updates_reconstruction_copy(
self,
synthesis_mocks: SynthesisMocks,
reconstruction: MockReconstruction,
- features: FakeFeatures,
+ features: Features,
) -> None:
service = RegenerationService()
service._run(
reconstruction,
- synthesis_mocks.generator_name,
- cast(Features, features),
+ synthesis_mocks.channel_name,
FeatureKey.VOLUME,
- 1,
+ features,
)
updated = reconstruction.model_copy.return_value
- updated.update_generator_data.assert_called_once()
- reconstruction.update_generator_data.assert_not_called()
- call_args = updated.update_generator_data.call_args
- assert call_args.args[0] == synthesis_mocks.generator_name
+ updated.update_channel_data.assert_called_once()
+ reconstruction.update_channel_data.assert_not_called()
+ call_args = updated.update_channel_data.call_args
+ assert call_args.args[0] == synthesis_mocks.channel_name
def test_run_carries_the_reference_pitch_through_an_arpeggio_edit(
self,
synthesis_mocks: SynthesisMocks,
reconstruction: MockReconstruction,
- features: FakeFeatures,
+ features: Features,
) -> None:
"""An arpeggio edit stores the reference pitch the edit was made from.
@@ -263,41 +238,34 @@ def test_run_carries_the_reference_pitch_through_an_arpeggio_edit(
service._run(
reconstruction,
- synthesis_mocks.generator_name,
- cast(Features, features),
+ synthesis_mocks.channel_name,
FeatureKey.ARPEGGIO,
- np.array([12, 0], dtype=np.int8),
+ features,
)
- call_args = reconstruction.model_copy.return_value.update_generator_data.call_args
+ call_args = reconstruction.model_copy.return_value.update_channel_data.call_args
assert call_args.args[3] == REFERENCE_PITCH
def test_run_carries_a_moved_reference_pitch(
self,
synthesis_mocks: SynthesisMocks,
reconstruction: MockReconstruction,
- features: FakeFeatures,
+ features: Features,
) -> None:
- """The pitch stepper's edit stores the new reference pitch."""
- moved_pitch = REFERENCE_PITCH + 12
+ """The pitch stepper's edit reaches the reconstruction as the reference it stores."""
+ moved = features.model_copy(update={"initial_pitch": REFERENCE_PITCH + 12})
service = RegenerationService()
- service._run(
- reconstruction,
- synthesis_mocks.generator_name,
- cast(Features, features),
- FeatureKey.INITIAL_PITCH,
- moved_pitch,
- )
+ service._run(reconstruction, synthesis_mocks.channel_name, FeatureKey.INITIAL_PITCH, moved)
- call_args = reconstruction.model_copy.return_value.update_generator_data.call_args
- assert call_args.args[3] == moved_pitch
+ _, _, _, initial_pitch, _ = reconstruction.model_copy.return_value.update_channel_data.call_args.args
+ assert initial_pitch == REFERENCE_PITCH + 12
def test_run_calls_generator_for_each_instruction(
self,
synthesis_mocks: SynthesisMocks,
reconstruction: MockReconstruction,
- features: FakeFeatures,
+ features: Features,
) -> None:
extra_instruction = MagicMock()
synthesis_mocks.exporter.from_features.return_value = [
@@ -308,10 +276,9 @@ def test_run_calls_generator_for_each_instruction(
service._run(
reconstruction,
- synthesis_mocks.generator_name,
- cast(Features, features),
+ synthesis_mocks.channel_name,
FeatureKey.VOLUME,
- 1,
+ features,
)
assert synthesis_mocks.generator.call_count == 2
@@ -329,15 +296,14 @@ def test_run_exception_emits_service_error(
mock_exporter.get_generator_type.side_effect = exception
with patch(
- "sampletones_application.services.regeneration.GENERATOR_NAME_TO_EXPORTER_MAP",
- {GeneratorName.PULSE1: mock_exporter},
+ "sampletones_application.services.regeneration.service.CHANNEL_TO_EXPORTER_MAP",
+ {ChannelName.PULSE1: mock_exporter},
):
service._run(
reconstruction,
- GeneratorName.PULSE1,
- cast(Features, {}),
+ ChannelName.PULSE1,
FeatureKey.VOLUME,
- 1,
+ cast(Features, {}),
)
assert len(results) == 1
@@ -354,18 +320,17 @@ def test_run_exception_does_not_update_reconstruction(
mock_exporter.get_generator_type.side_effect = RuntimeError("fail")
with patch(
- "sampletones_application.services.regeneration.GENERATOR_NAME_TO_EXPORTER_MAP",
- {GeneratorName.PULSE1: mock_exporter},
+ "sampletones_application.services.regeneration.service.CHANNEL_TO_EXPORTER_MAP",
+ {ChannelName.PULSE1: mock_exporter},
):
service._run(
reconstruction,
- GeneratorName.PULSE1,
- cast(Features, {}),
+ ChannelName.PULSE1,
FeatureKey.VOLUME,
- 1,
+ cast(Features, {}),
)
- reconstruction.update_generator_data.assert_not_called()
+ reconstruction.update_channel_data.assert_not_called()
class TestClearingEveryEnvelope:
@@ -379,18 +344,17 @@ class TestClearingEveryEnvelope:
@staticmethod
def _regenerated(reconstruction: Reconstruction) -> Reconstruction:
"""The reconstruction the service returns once every dimension is left to the channel."""
- features = reconstruction.export()[GeneratorName.PULSE1]
- features.leave_to_channel([FeatureKey.ARPEGGIO, FeatureKey.DUTY_CYCLE])
+ exported = reconstruction.export()[ChannelName.PULSE1]
+ features = exported.leave_to_channel(exported.envelopes)
service = RegenerationService()
results: List[Any] = []
service.subscribe(results.append)
service._run(
reconstruction,
- GeneratorName.PULSE1,
- features,
+ ChannelName.PULSE1,
FeatureKey.VOLUME,
- np.array([], dtype=np.int8),
+ features,
)
assert isinstance(results[0], ServiceSuccess)
@@ -405,8 +369,8 @@ def test_a_cleared_instrument_takes_its_channel_out_of_play(
regenerated = self._regenerated(reconstruction)
- assert regenerated.instructions[GeneratorName.PULSE1] == []
- assert regenerated.playing_generators == ()
+ assert regenerated.instructions[ChannelName.PULSE1] == []
+ assert regenerated.playing_channels == ()
def test_a_cleared_instrument_sounds_as_an_empty_waveform(
self,
@@ -427,12 +391,10 @@ def test_the_cleared_channel_records_every_dimension_as_the_channels(
regenerated = self._regenerated(reconstruction)
- assert regenerated.held_features[GeneratorName.PULSE1] == (
- FeatureKey.VOLUME,
- FeatureKey.ARPEGGIO,
- FeatureKey.DUTY_CYCLE,
+ assert regenerated.held_features[ChannelName.PULSE1] == tuple(
+ supported_features(CHANNEL_GENERATOR_KIND[ChannelName.PULSE1])
)
- assert not regenerated.export()[GeneratorName.PULSE1].has_frames
+ assert not regenerated.export()[ChannelName.PULSE1].has_frames
def test_the_reconstruction_the_edit_was_made_from_keeps_playing(
self,
@@ -442,11 +404,11 @@ def test_the_reconstruction_the_edit_was_made_from_keeps_playing(
self._regenerated(reconstruction)
- assert reconstruction.playing_generators == (GeneratorName.PULSE1,)
+ assert reconstruction.playing_channels == (ChannelName.PULSE1,)
class TestRegenerationServiceCancellationConstraints:
- """Tests that document the non-preemptive cancellation behaviour.
+ """Tests that document the non-preemptive cancellation behavior.
cancel() only prevents new tasks from starting. It does NOT interrupt
synthesis that is already in progress.
@@ -455,7 +417,7 @@ class TestRegenerationServiceCancellationConstraints:
def test_cancel_while_running_does_not_interrupt_synthesis(
self,
synthesis_mocks: SynthesisMocks,
- features: FakeFeatures,
+ features: Features,
) -> None:
service = RegenerationService()
results: List[Any] = []
@@ -482,10 +444,9 @@ def blocking_from_features(edited_features: Any) -> List[MagicMock]:
thread = threading.Thread(
target=lambda: service._run(
reconstruction,
- synthesis_mocks.generator_name,
- cast(Features, features),
+ synthesis_mocks.channel_name,
FeatureKey.VOLUME,
- 1,
+ features,
),
)
thread.start()
@@ -511,19 +472,17 @@ def test_cancel_after_completion_prevents_new_tasks(
service.start(
reconstruction,
- synthesis_mocks.generator_name,
- cast(Features, {}),
+ synthesis_mocks.channel_name,
FeatureKey.VOLUME,
- 1,
+ cast(Features, {}),
)
service.cancel()
second_result = service.start(
reconstruction,
- synthesis_mocks.generator_name,
- cast(Features, {}),
+ synthesis_mocks.channel_name,
FeatureKey.VOLUME,
- 2,
+ cast(Features, {}),
)
assert second_result is False
diff --git a/tests/unit/sampletones_application/services/test_result.py b/tests/unit/sampletones_application/services/test_result.py
index db1ed71a9..829598fda 100644
--- a/tests/unit/sampletones_application/services/test_result.py
+++ b/tests/unit/sampletones_application/services/test_result.py
@@ -4,7 +4,7 @@
import pytest
from sampletones_application.services.result import (
- ServiceCancelled,
+ ServiceCanceled,
ServiceError,
ServiceIntermediate,
ServiceProgress,
@@ -102,18 +102,18 @@ def test_same_instance_equals_itself(self) -> None:
assert error == error # noqa: PLR0124
-class TestServiceCancelled:
+class TestServiceCanceled:
def test_instantiates(self) -> None:
- cancelled = ServiceCancelled()
- assert isinstance(cancelled, ServiceCancelled)
+ canceled = ServiceCanceled()
+ assert isinstance(canceled, ServiceCanceled)
def test_frozen(self) -> None:
- cancelled = ServiceCancelled()
+ canceled = ServiceCanceled()
with pytest.raises(FrozenInstanceError):
- cancelled.x = 1 # type: ignore[attr-defined]
+ canceled.x = 1 # type: ignore[attr-defined]
def test_equality(self) -> None:
- assert ServiceCancelled() == ServiceCancelled()
+ assert ServiceCanceled() == ServiceCanceled()
class TestServiceIntermediate:
diff --git a/tests/unit/sampletones_application/test_application_channels.py b/tests/unit/sampletones_application/test_application_channels.py
index 39a46fc0f..3f8111785 100644
--- a/tests/unit/sampletones_application/test_application_channels.py
+++ b/tests/unit/sampletones_application/test_application_channels.py
@@ -8,7 +8,7 @@
from sampletones_application.application import Application
from sampletones_application.categories.hierarchy import Tab
-from sampletones_core.constants.enums import GeneratorName
+from sampletones_core.constants.enums import ChannelName
from tests.suite.base import BaseTestSuite
from tests.suite.case import BaseRegularTestCase
@@ -25,20 +25,20 @@ class Harness:
"""An application standing in one tab, recording which surface a channel key reaches."""
def __init__(self, tab: Tab) -> None:
- self.switched: List[Tuple[Surface, GeneratorName]] = []
+ self.switched: List[Tuple[Surface, ChannelName]] = []
self.application = Application.__new__(Application)
self.application._shell = MagicMock()
self.application._shell.get_current_tab.return_value = tab
self.application._main_tab = MagicMock()
- self.application._main_tab.toggle_generator = partial(self._record, Surface.MAIN)
+ self.application._main_tab.toggle_channel = partial(self._record, Surface.MAIN)
self.application._reconstructions_tab = MagicMock()
- self.application._reconstructions_tab.toggle_generator = partial(self._record, Surface.RECONSTRUCTIONS)
+ self.application._reconstructions_tab.toggle_channel = partial(self._record, Surface.RECONSTRUCTIONS)
self.application._sequencer_tab = MagicMock()
self.application._sequencer_tab.toggle_channel = partial(self._record, Surface.SEQUENCER)
- def _record(self, surface: Surface, generator: GeneratorName) -> None:
- self.switched.append((surface, generator))
+ def _record(self, surface: Surface, channel: ChannelName) -> None:
+ self.switched.append((surface, channel))
class TestToggleChannel(BaseTestSuite):
@@ -51,7 +51,7 @@ class TestCase(BaseRegularTestCase):
test_cases = (
TestCase(
- label="the main tab switches a generator of the reconstructor",
+ label="the main tab switches a channel of the reconstructor",
tab=Tab.MAIN,
expected=Surface.MAIN,
),
@@ -80,9 +80,9 @@ class TestCase(BaseRegularTestCase):
def test_the_surface_a_channel_key_reaches(self, test_case: TestCase) -> None:
harness = Harness(test_case.tab)
- harness.application._toggle_channel(GeneratorName.TRIANGLE)
+ harness.application._toggle_channel(ChannelName.TRIANGLE)
- assert harness.switched == [(test_case.expected, GeneratorName.TRIANGLE)]
+ assert harness.switched == [(test_case.expected, ChannelName.TRIANGLE)]
@pytest.mark.parametrize(
"test_case",
@@ -93,10 +93,10 @@ def test_every_channel_reaches_the_same_surface(self, test_case: TestCase) -> No
"""The four keys stand together, so a tab answers all of them or none."""
harness = Harness(test_case.tab)
- for generator in GeneratorName:
- harness.application._toggle_channel(generator)
+ for channel in ChannelName:
+ harness.application._toggle_channel(channel)
- assert harness.switched == [(test_case.expected, generator) for generator in GeneratorName]
+ assert harness.switched == [(test_case.expected, channel) for channel in ChannelName]
class TestMuteChannel:
@@ -104,6 +104,6 @@ def test_the_menu_gesture_switches_the_sequencer_mix_from_any_tab(self) -> None:
"""The Channels submenu shows the sequencer's mix, so choosing an item switches that mix."""
harness = Harness(Tab.MAIN)
- harness.application._mute_channel(GeneratorName.NOISE)
+ harness.application._mute_channel(ChannelName.NOISE)
- assert harness.switched == [(Surface.SEQUENCER, GeneratorName.NOISE)]
+ assert harness.switched == [(Surface.SEQUENCER, ChannelName.NOISE)]
diff --git a/tests/unit/sampletones_application/test_application_retune.py b/tests/unit/sampletones_application/test_application_retune.py
index 66684f8e6..5d2cf4377 100644
--- a/tests/unit/sampletones_application/test_application_retune.py
+++ b/tests/unit/sampletones_application/test_application_retune.py
@@ -2,25 +2,31 @@
from unittest.mock import MagicMock
from sampletones_application.application import Application
-from sampletones_application.services.result import ServiceCancelled
+from sampletones_application.services.result import ServiceCanceled
from sampletones_application.services.retune import RetunedSample
+from sampletones_core.project.voices.sample import Sample
-def _retuned(sample_id: str, rate: int) -> RetunedSample:
+def _sample_double() -> Sample:
+ """A real project sample over a stand-in reconstruction, since the routing tells the kinds apart."""
+ return Sample(name="lead", reconstruction=MagicMock())
+
+
+def _retuned(voice_id: str, rate: int) -> RetunedSample:
reconstruction = MagicMock()
reconstruction.config.nes_frequency = rate
- return RetunedSample(sample_id=sample_id, reconstruction=reconstruction)
+ return RetunedSample(voice_id=voice_id, reconstruction=reconstruction)
def _app(
current_rate: int,
- sample: Optional[MagicMock],
+ sample: Optional[Sample],
open_reconstruction: Optional[MagicMock] = None,
) -> Application:
app = Application.__new__(Application)
app.project_manager = MagicMock()
app.project_manager.current.settings.nes_frequency = current_rate
- app.project_manager.current.samples.get.return_value = sample
+ app.project_manager.current.voices.get.return_value = sample
app.reconstruction_manager = MagicMock()
app.reconstruction_manager.reconstruction = open_reconstruction
app.history = MagicMock()
@@ -32,7 +38,7 @@ def _app(
class TestApplyRetunedSample:
def test_swaps_the_reconstruction_when_the_rate_matches(self) -> None:
- app = _app(current_rate=60, sample=MagicMock())
+ app = _app(current_rate=60, sample=_sample_double())
retuned = _retuned("lead", 60)
app._apply_retuned_sample(retuned)
@@ -40,7 +46,7 @@ def test_swaps_the_reconstruction_when_the_rate_matches(self) -> None:
app.project_controller.replace_sample_reconstruction.assert_called_once_with("lead", retuned.reconstruction)
def test_discards_a_stale_result_from_a_superseded_rate(self) -> None:
- app = _app(current_rate=30, sample=MagicMock())
+ app = _app(current_rate=30, sample=_sample_double())
retuned = _retuned("lead", 60)
app._apply_retuned_sample(retuned)
@@ -57,43 +63,43 @@ def test_ignores_a_removed_sample(self) -> None:
def test_rebinds_the_open_editor_when_it_shows_the_sample(self) -> None:
open_reconstruction = MagicMock()
- sample = MagicMock()
+ sample = _sample_double()
sample.reconstruction = open_reconstruction
app = _app(current_rate=60, sample=sample, open_reconstruction=open_reconstruction)
retuned = _retuned("lead", 60)
app._apply_retuned_sample(retuned)
- app.reconstruction_manager.apply_regenerated.assert_called_once_with(retuned.reconstruction)
+ app.reconstruction_manager.apply_edited.assert_called_once_with(retuned.reconstruction)
app._reconstructions_tab.update_reconstruction.assert_called_once()
def test_leaves_the_editor_alone_when_a_different_sample_is_open(self) -> None:
- sample = MagicMock()
+ sample = _sample_double()
sample.reconstruction = MagicMock()
app = _app(current_rate=60, sample=sample, open_reconstruction=MagicMock())
retuned = _retuned("lead", 60)
app._apply_retuned_sample(retuned)
- app.reconstruction_manager.apply_regenerated.assert_not_called()
+ app.reconstruction_manager.apply_edited.assert_not_called()
app._reconstructions_tab.update_reconstruction.assert_not_called()
-def _sample(sample_id: str, rate: int) -> MagicMock:
- sample = MagicMock()
- sample.id = sample_id
+def _sample(voice_id: str, rate: int) -> Sample:
+ sample = _sample_double()
+ sample.id = voice_id
sample.reconstruction.config.nes_frequency = rate
return sample
def _app_for_rate(
- samples: List[MagicMock],
+ samples: List[Sample],
open_reconstruction: Optional[MagicMock],
running: bool = False,
) -> Application:
app = Application.__new__(Application)
app.project_manager = MagicMock()
- app.project_manager.current.samples = samples
+ app.project_manager.current.voices = samples
app.reconstruction_manager = MagicMock()
app.reconstruction_manager.reconstruction = open_reconstruction
app.retune_service = MagicMock()
@@ -138,13 +144,13 @@ def test_does_not_dim_when_no_reconstruction_is_open(self) -> None:
def test_restores_the_dim_when_the_batch_finishes(self) -> None:
app = _app_for_rate([], open_reconstruction=None, running=False)
- app._on_retune_result(ServiceCancelled())
+ app._on_retune_result(ServiceCanceled())
app._reconstructions_tab.set_reconstruction_dimmed.assert_called_once_with(False)
def test_keeps_the_dim_while_the_batch_is_running(self) -> None:
app = _app_for_rate([], open_reconstruction=None, running=True)
- app._on_retune_result(ServiceCancelled())
+ app._on_retune_result(ServiceCanceled())
app._reconstructions_tab.set_reconstruction_dimmed.assert_not_called()
diff --git a/tests/unit/sampletones_application/test_application_sample_rebind.py b/tests/unit/sampletones_application/test_application_sample_rebind.py
index dabb9a97c..c57350919 100644
--- a/tests/unit/sampletones_application/test_application_sample_rebind.py
+++ b/tests/unit/sampletones_application/test_application_sample_rebind.py
@@ -2,15 +2,21 @@
from unittest.mock import MagicMock
from sampletones_application.application import Application
+from sampletones_core.project.voices.sample import Sample
+
+
+def _sample_double() -> Sample:
+ """A real project sample over a stand-in reconstruction, since the routing tells the kinds apart."""
+ return Sample(name="lead", reconstruction=MagicMock())
def _app(
- sample: Optional[MagicMock],
+ sample: Optional[Sample],
open_reconstruction: Optional[MagicMock],
) -> Application:
app = Application.__new__(Application)
app.project_manager = MagicMock()
- app.project_manager.current.sample.return_value = sample
+ app.project_manager.current.voice.return_value = sample
app.reconstruction_manager = MagicMock()
app.reconstruction_manager.reconstruction = open_reconstruction
app._reconstructions_tab = MagicMock()
@@ -20,34 +26,34 @@ def _app(
class TestRebindReplacedSample:
def test_rebinds_the_editor_showing_the_replaced_sample(self) -> None:
outgoing = MagicMock()
- sample = MagicMock()
+ sample = _sample_double()
sample.reconstruction = outgoing
app = _app(sample=sample, open_reconstruction=outgoing)
incoming = MagicMock()
app._rebind_replaced_sample("bass-id", incoming)
- app.reconstruction_manager.apply_regenerated.assert_called_once_with(incoming)
+ app.reconstruction_manager.apply_edited.assert_called_once_with(incoming)
app._reconstructions_tab.update_reconstruction.assert_called_once()
def test_leaves_the_editor_alone_when_a_different_sample_is_open(self) -> None:
- sample = MagicMock()
+ sample = _sample_double()
sample.reconstruction = MagicMock()
app = _app(sample=sample, open_reconstruction=MagicMock())
app._rebind_replaced_sample("bass-id", MagicMock())
- app.reconstruction_manager.apply_regenerated.assert_not_called()
+ app.reconstruction_manager.apply_edited.assert_not_called()
app._reconstructions_tab.update_reconstruction.assert_not_called()
def test_leaves_the_editor_alone_when_no_document_is_open(self) -> None:
- sample = MagicMock()
+ sample = _sample_double()
sample.reconstruction = MagicMock()
app = _app(sample=sample, open_reconstruction=None)
app._rebind_replaced_sample("bass-id", MagicMock())
- app.reconstruction_manager.apply_regenerated.assert_not_called()
+ app.reconstruction_manager.apply_edited.assert_not_called()
app._reconstructions_tab.update_reconstruction.assert_not_called()
def test_ignores_an_unknown_sample(self) -> None:
@@ -55,5 +61,5 @@ def test_ignores_an_unknown_sample(self) -> None:
app._rebind_replaced_sample("gone", MagicMock())
- app.reconstruction_manager.apply_regenerated.assert_not_called()
+ app.reconstruction_manager.apply_edited.assert_not_called()
app._reconstructions_tab.update_reconstruction.assert_not_called()
diff --git a/tests/unit/sampletones_application/test_busy_lock.py b/tests/unit/sampletones_application/test_busy_lock.py
index 5449f8eef..dd84a2d2f 100644
--- a/tests/unit/sampletones_application/test_busy_lock.py
+++ b/tests/unit/sampletones_application/test_busy_lock.py
@@ -1,6 +1,14 @@
+from pathlib import Path
from unittest.mock import MagicMock
from sampletones_application.application import Application
+from sampletones_application.services.export.kind import ExportKind
+from sampletones_application.services.export.success import ExportSuccess
+from sampletones_application.services.result import ServiceProgress, ServiceStarted
+from sampletones_core.exports.stage import ExportStage
+
+NOTHING_MEASURED: int = 0
+BYTES_SO_FAR: int = 812
def _application(
@@ -8,6 +16,7 @@ def _application(
converter_running: bool = False,
library_generating: bool = False,
rendering: bool = False,
+ exporting: bool = False,
) -> Application:
"""An application with only the attributes the busy methods touch, bypassing the full composition
root constructor."""
@@ -19,13 +28,15 @@ def _application(
application._reconstructions_tab = MagicMock()
application._render_coordinator = MagicMock()
application._render_coordinator.is_active = rendering
+ application.export_service = MagicMock()
+ application.export_service.is_running.return_value = exporting
application._update_menu = MagicMock()
return application
class TestBusySourceOfTruth:
- """``_is_operation_active`` is the single busy authority: a conversion, a library generation or
- a render each make it true, and only an idle set makes it false."""
+ """``_is_operation_active`` is the single busy authority: a conversion, a library generation, a
+ render or an export each make it true, and only an idle set makes it false."""
def test_busy_while_converter_runs(self) -> None:
assert _application(converter_running=True)._is_operation_active() is True
@@ -36,10 +47,45 @@ def test_busy_while_library_generates(self) -> None:
def test_busy_while_song_renders(self) -> None:
assert _application(rendering=True)._is_operation_active() is True
+ def test_busy_while_an_export_writes(self) -> None:
+ assert _application(exporting=True)._is_operation_active() is True
+
def test_idle_when_none_runs(self) -> None:
assert _application()._is_operation_active() is False
+class TestExportEdges:
+ """An export claims the application while it writes, so its start and its end are busy edges."""
+
+ def test_a_start_refreshes_the_busy_state(self) -> None:
+ application = _application()
+ application._on_export_activity(ServiceStarted(total=NOTHING_MEASURED))
+ application._update_menu.assert_called_once_with()
+
+ def test_a_report_mid_run_is_no_edge(self) -> None:
+ application = _application()
+ application._on_export_activity(
+ ServiceProgress(
+ completed=BYTES_SO_FAR,
+ total=NOTHING_MEASURED,
+ current_item=ExportStage.COMPRESSING,
+ )
+ )
+ application._update_menu.assert_not_called()
+
+ def test_a_finished_export_refreshes_the_busy_state(self) -> None:
+ application = _application()
+ application._on_export_activity(
+ ExportSuccess(
+ kind=ExportKind.SAMPLE,
+ filepath=Path("song.nsf"),
+ export_format=None,
+ truncation=None,
+ )
+ )
+ application._update_menu.assert_called_once_with()
+
+
class TestBusyRefreshPropagation:
"""A busy-state change nudges both tabs to re-evaluate their action buttons and the menu to
re-read what may start another such operation; each reads the live busy authority for itself,
diff --git a/tests/unit/sampletones_application/test_project_properties_history.py b/tests/unit/sampletones_application/test_project_properties_history.py
index ce9ff781b..8d9ddf565 100644
--- a/tests/unit/sampletones_application/test_project_properties_history.py
+++ b/tests/unit/sampletones_application/test_project_properties_history.py
@@ -47,7 +47,7 @@ def test_changed_fields_group_into_one_entry(self) -> None:
info = application.project_controller.project.info
assert (info.title, info.author, info.comment) == ("Title", "Author", "Comment")
- def test_the_metre_joins_the_same_entry_as_the_info(self) -> None:
+ def test_the_meter_joins_the_same_entry_as_the_info(self) -> None:
"""The highlights are project settings, and the dialog commits them beside the info."""
application = _application()
diff --git a/tests/unit/sampletones_application/test_startup.py b/tests/unit/sampletones_application/test_startup.py
index dac387964..deed263c4 100644
--- a/tests/unit/sampletones_application/test_startup.py
+++ b/tests/unit/sampletones_application/test_startup.py
@@ -12,6 +12,19 @@
from sampletones_application.config.profile import UserProfile
from sampletones_application.constants.keybindings import DEFAULT_SCHEME_NAME
from sampletones_application.logic.history.action import HistoryAction
+from sampletones_application.tags.general import (
+ SUF_BUTTON,
+ SUF_GROUP,
+ SUF_STRIP,
+ SUF_TABLE,
+ SUF_TEXT,
+ TAG_GLOBAL_THEME_STEMS_ROW_INERT,
+)
+from sampletones_application.tags.main import (
+ TAG_MAIN_CONVERTER_TOOLTIP_HIERARCHY_MODE,
+ TAG_MAIN_CONVERTER_WINDOW_STEMS,
+)
+from sampletones_application.ui.elements.stems.list import GUIStemsList
from sampletones_application.utils.gui.keyboard.event import KeyEvent
from sampletones_application.utils.gui.shortcuts.ids import (
CHANNEL_SHORTCUT_IDS,
@@ -22,10 +35,12 @@
stop_background_workers,
)
from sampletones_application.utils.parallelization.thread import SingleThreadExecutor
-from sampletones_core.constants.enums import GeneratorName
+from sampletones_application.view_model.main.converter import ConversionPhase
+from sampletones_core.constants.enums import ChannelName
from sampletones_core.reconstructions import Reconstruction
REBOUND_UNDO: Final[Dict[str, str]] = {"Undo": "Ctrl+Alt+U"}
+DRAG_PAYLOAD_SLOT: Final[int] = 3
_DPG_DISPLAY_FUNCTIONS = [
"create_context",
@@ -102,14 +117,14 @@ def dpg_context(self) -> Generator[Any, Application, Any]:
SingleThreadExecutor.reset_shutdown()
dpg.destroy_context()
- def test_initialises_without_error(self, tmp_path: Path) -> None:
+ def test_initializes_without_error(self, tmp_path: Path) -> None:
with ExitStack() as stack:
for display_patch in _display_patches():
stack.enter_context(display_patch)
Application(profile=_profile(tmp_path))
- def test_initialises_where_nothing_can_play(self, tmp_path: Path) -> None:
+ def test_initializes_where_nothing_can_play(self, tmp_path: Path) -> None:
"""Editing a song, exporting a module and rendering to a file need no output device.
The rate the audio is rendered at is the consumer's to state, so a machine offering no
@@ -196,7 +211,7 @@ def test_another_scheme_hands_its_keys_to_the_dispatcher(self, application: Appl
class TestStartupRestoreDelegation:
"""Application only forwards the startup restore to the domain coordinators, which
are the recovery boundary (docs/development/architecture.md § Error Handling Policy). The
- recovery behaviour itself is covered by the coordinator tests.
+ recovery behavior itself is covered by the coordinator tests.
"""
def test_project_restore_delegates_to_coordinator(self, app: Application) -> None:
@@ -235,7 +250,7 @@ def _embed_sample(
reconstruction = reconstruction_factory()
with app.history.transaction(HistoryAction.ADD_SAMPLE):
sample = app.project_controller.add_sample(reconstruction, "Lead")
- app._edit_project_sample(sample.id)
+ app._edit_project_voice(sample.id)
return sample
def test_embedded_reconstruction_is_owned_and_not_saveable(
@@ -276,7 +291,7 @@ def test_save_as_detaches_open_document_from_the_project(
assert app._build_menu_bar_viewmodel().reconstruction_saveable
assert app.reconstruction_manager.reconstruction is not original
assert sample.reconstruction is original
- assert original in [sample.reconstruction for sample in app.project_manager.current.samples]
+ assert original in [sample.reconstruction for sample in app.project_manager.current.voices]
class TestAddOpenReconstructionToSequencer:
@@ -306,12 +321,12 @@ def test_adding_open_document_keeps_its_source_audio(
) -> None:
self._open_file_backed_reconstruction(app, reconstruction_factory, tmp_path)
app.project_controller.new()
- source_before = app.reconstruction_manager.audio_filepath
+ source_before = app.reconstruction_manager.source_paths
app._add_current_reconstruction_to_sequencer()
- assert source_before is not None
- assert app.reconstruction_manager.audio_filepath == source_before
+ assert source_before
+ assert app.reconstruction_manager.source_paths == source_before
assert app._build_menu_bar_viewmodel().locate_audio_enabled
def test_embedded_sample_is_a_detached_copy(
@@ -325,9 +340,9 @@ def test_embedded_sample_is_a_detached_copy(
app._add_current_reconstruction_to_sequencer()
- sample = app.project_manager.current.samples[0]
+ sample = app.project_manager.current.voices[0]
assert sample.reconstruction is not app.reconstruction_manager.reconstruction
- assert sample.reconstruction.audio_filepath is None
+ assert sample.reconstruction.audio_filepath == ()
assert not app._editing_project_sample()
@@ -347,36 +362,36 @@ class TestChannelKeys:
"""
@staticmethod
- def _press(app: Application, generator: GeneratorName, tab: Tab) -> None:
+ def _press(app: Application, channel: ChannelName, tab: Tab) -> None:
with patch.object(app._shell, "get_current_tab", return_value=tab):
- _press_shortcut(app, CHANNEL_SHORTCUT_IDS[generator])
+ _press_shortcut(app, CHANNEL_SHORTCUT_IDS[channel])
def test_the_main_tab_switches_the_generator_a_reconstruction_is_built_from(self, app: Application) -> None:
- selected = frozenset(app.config_manager.config.generation.generators)
+ selected = frozenset(app.config_manager.config.generation.channels)
- self._press(app, GeneratorName.TRIANGLE, Tab.MAIN)
+ self._press(app, ChannelName.TRIANGLE, Tab.MAIN)
- assert frozenset(app.config_manager.config.generation.generators) == selected ^ {GeneratorName.TRIANGLE}
+ assert frozenset(app.config_manager.config.generation.channels) == selected ^ {ChannelName.TRIANGLE}
def test_the_sequencer_switches_its_mix(self, app: Application) -> None:
- self._press(app, GeneratorName.NOISE, Tab.SEQUENCER)
+ self._press(app, ChannelName.NOISE, Tab.SEQUENCER)
- assert app._sequencer_tab.channels.is_muted(GeneratorName.NOISE)
+ assert app._sequencer_tab.channels.is_muted(ChannelName.NOISE)
def test_a_second_press_returns_the_mix_it_started_from(self, app: Application) -> None:
- self._press(app, GeneratorName.PULSE1, Tab.SEQUENCER)
- self._press(app, GeneratorName.PULSE1, Tab.SEQUENCER)
+ self._press(app, ChannelName.PULSE1, Tab.SEQUENCER)
+ self._press(app, ChannelName.PULSE1, Tab.SEQUENCER)
assert not app._sequencer_tab.channels.any_muted
def test_the_reconstructions_tab_holding_nothing_leaves_the_mix_alone(self, app: Application) -> None:
"""With no reconstruction loaded every slice reads as unavailable, so the key rests there."""
- self._press(app, GeneratorName.PULSE2, Tab.RECONSTRUCTIONS)
+ self._press(app, ChannelName.PULSE2, Tab.RECONSTRUCTIONS)
assert not app._sequencer_tab.channels.any_muted
def test_the_main_tab_leaves_the_sequencer_mix_alone(self, app: Application) -> None:
- self._press(app, GeneratorName.PULSE1, Tab.MAIN)
+ self._press(app, ChannelName.PULSE1, Tab.MAIN)
assert not app._sequencer_tab.channels.any_muted
@@ -399,3 +414,139 @@ def test_the_key_puts_its_tab_on_screen(self, app: Application, tab: Tab) -> Non
def test_the_key_answers_while_a_field_is_edited(self, app: Application, tab: Tab) -> None:
"""Naming a tab reaches it the way stepping to the next one does, typing included."""
assert app._shortcut_source.shortcut(TAB_SHORTCUT_IDS[tab]).field_transparent
+
+
+def stems_list(app: Application) -> GUIStemsList:
+ """The converter card's stems list, which owns the tags its rows carry."""
+ return app._main_tab._converter_panel.stems_list
+
+
+def drop(tag: str, payload: str) -> None:
+ """Deliver ``payload`` to whatever ``tag`` accepts drops with, the way DearPyGui would."""
+ dpg.get_item_configuration(tag)["drop_callback"](dpg.get_alias_id(tag), payload)
+
+
+class TestConverterStemsCard:
+ """Gathering recordings paints the converter card: a row each, carrying what the reader set."""
+
+ def _gather(self, app: Application, tmp_path: Path, names: List[str]) -> List[Path]:
+ paths = []
+ for name in names:
+ path = tmp_path / name
+ path.touch()
+ paths.append(path)
+
+ converter_logic = app._main_tab._converter_logic
+ converter_logic.set_stems_mode(True)
+ converter_logic.add_sources(paths)
+ return paths
+
+ def test_a_row_is_built_for_every_recording(self, app: Application, tmp_path: Path) -> None:
+ paths = self._gather(app, tmp_path, ["a.wav", "b.wav"])
+
+ for path in paths:
+ assert dpg.does_item_exist(stems_list(app).row_tag(str(path), SUF_GROUP))
+ assert dpg.does_item_exist(stems_list(app).row_tag(str(path), SUF_BUTTON))
+
+ def test_a_rows_channels_show_what_was_set(self, app: Application, tmp_path: Path) -> None:
+ """The row offers a checkbox per channel the configuration enables, ticked as the row holds it."""
+ path = self._gather(app, tmp_path, ["a.wav"])[0]
+ converter_logic = app._main_tab._converter_logic
+ enabled = list(converter_logic._config_manager.config.generation.channels)
+ kept, cleared = enabled[-1], enabled[0]
+
+ converter_logic.set_source_channels(path, frozenset({kept}))
+
+ assert dpg.get_value(stems_list(app).channel_tag(str(path), kept)) is True
+ assert dpg.get_value(stems_list(app).channel_tag(str(path), cleared)) is False
+
+ def test_removing_a_recording_takes_its_row_with_it(self, app: Application, tmp_path: Path) -> None:
+ first, second = self._gather(app, tmp_path, ["a.wav", "b.wav"])
+
+ app._main_tab._converter_logic.remove_source(first)
+
+ assert not dpg.does_item_exist(stems_list(app).row_tag(str(first), SUF_GROUP))
+ assert dpg.does_item_exist(stems_list(app).row_tag(str(second), SUF_GROUP))
+
+ def test_leaving_stems_mode_hides_the_list(self, app: Application, tmp_path: Path) -> None:
+ self._gather(app, tmp_path, ["a.wav"])
+ assert dpg.get_item_configuration(TAG_MAIN_CONVERTER_WINDOW_STEMS)["show"] is True
+
+ app._main_tab._converter_logic.set_stems_mode(False)
+
+ assert dpg.get_item_configuration(TAG_MAIN_CONVERTER_WINDOW_STEMS)["show"] is False
+
+ def test_the_list_stays_on_screen_while_a_conversion_runs(self, app: Application, tmp_path: Path) -> None:
+ """The setup is what a running conversion is making, so it keeps saying what that is."""
+ path = self._gather(app, tmp_path, ["a.wav"])[0]
+ converter_logic = app._main_tab._converter_logic
+
+ converter_logic._phase = ConversionPhase.RUNNING
+ converter_logic.refresh_view()
+ converter_logic._emit_view_model("running", 0.5)
+
+ assert dpg.get_item_configuration(TAG_MAIN_CONVERTER_WINDOW_STEMS)["show"] is True
+ assert dpg.get_item_configuration(stems_list(app).row_tag(str(path), SUF_BUTTON))["enabled"] is False
+
+ def test_a_level_draws_its_own_band(self, app: Application, tmp_path: Path) -> None:
+ first, second = self._gather(app, tmp_path, ["a.wav", "b.wav"])
+ converter_logic = app._main_tab._converter_logic
+
+ converter_logic.isolate_source(second)
+
+ assert dpg.does_item_exist(stems_list(app).level_tag(0, SUF_TABLE))
+ assert dpg.does_item_exist(stems_list(app).level_tag(1, SUF_TABLE))
+ assert dpg.does_item_exist(stems_list(app).level_tag(2, SUF_STRIP))
+ assert dpg.get_item_parent(stems_list(app).row_tag(str(first), SUF_GROUP)) == stems_list(app).level_tag(
+ 0, SUF_TABLE
+ )
+ assert dpg.get_item_parent(stems_list(app).row_tag(str(second), SUF_GROUP)) == stems_list(app).level_tag(
+ 1, SUF_TABLE
+ )
+
+ def test_a_row_is_the_thing_you_drag_it_by(self, app: Application, tmp_path: Path) -> None:
+ path = self._gather(app, tmp_path, ["a.wav"])[0]
+
+ assert dpg.get_item_children(stems_list(app).row_tag(str(path), SUF_TEXT), DRAG_PAYLOAD_SLOT)
+
+ def test_dropping_a_recording_on_a_row_joins_that_rows_level(self, app: Application, tmp_path: Path) -> None:
+ first, second = self._gather(app, tmp_path, ["a.wav", "b.wav"])
+ converter_logic = app._main_tab._converter_logic
+ converter_logic.isolate_source(second)
+
+ drop(stems_list(app).row_tag(str(second), SUF_TEXT), str(first))
+
+ assert converter_logic._levels.level_count == 1
+
+ def test_dropping_a_recording_in_a_gap_opens_a_level(self, app: Application, tmp_path: Path) -> None:
+ first, _second = self._gather(app, tmp_path, ["a.wav", "b.wav"])
+ converter_logic = app._main_tab._converter_logic
+
+ drop(stems_list(app).level_tag(1, SUF_STRIP), str(first))
+
+ assert converter_logic._levels.level_count == 2
+ assert converter_logic._levels.level_of(first) == 1
+
+ def test_the_order_explanation_leaves_with_the_control_it_belongs_to(self, app: Application) -> None:
+ """A tooltip left live over a hidden widget's rectangle explains whatever moved into it."""
+ converter_logic = app._main_tab._converter_logic
+
+ converter_logic.set_stems_mode(True)
+ assert dpg.get_item_configuration(TAG_MAIN_CONVERTER_TOOLTIP_HIERARCHY_MODE)["show"] is True
+
+ converter_logic.set_stems_mode(False)
+ assert dpg.get_item_configuration(TAG_MAIN_CONVERTER_TOOLTIP_HIERARCHY_MODE)["show"] is False
+
+ def test_a_recording_holding_no_channel_grays_out_but_stays_listed(
+ self,
+ app: Application,
+ tmp_path: Path,
+ ) -> None:
+ path = self._gather(app, tmp_path, ["a.wav"])[0]
+
+ app._main_tab._converter_logic.set_source_channels(path, frozenset())
+
+ name_tag = stems_list(app).row_tag(str(path), SUF_TEXT)
+ assert dpg.does_item_exist(stems_list(app).row_tag(str(path), SUF_GROUP))
+ assert dpg.get_item_alias(dpg.get_item_theme(name_tag)) == TAG_GLOBAL_THEME_STEMS_ROW_INERT
+ assert dpg.get_item_configuration(name_tag)["enabled"] is True
diff --git a/tests/unit/sampletones_application/ui/elements/graphs/test_graph.py b/tests/unit/sampletones_application/ui/elements/graphs/test_graph.py
new file mode 100644
index 000000000..2ffdc02d3
--- /dev/null
+++ b/tests/unit/sampletones_application/ui/elements/graphs/test_graph.py
@@ -0,0 +1,45 @@
+from typing import Final
+from unittest.mock import patch
+
+from sampletones_application.ui.elements.graphs import graph as graph_module
+from sampletones_application.ui.elements.graphs.waveform import GUIWaveformGraph
+
+MODULE: Final[str] = "sampletones_application.ui.elements.graphs.graph"
+X_AXIS: Final[str] = "graph.x"
+Y_AXIS: Final[str] = "graph.y"
+
+
+def _graph() -> GUIWaveformGraph:
+ graph = GUIWaveformGraph.__new__(GUIWaveformGraph)
+ graph.x_axis_tag = X_AXIS
+ graph.y_axis_tag = Y_AXIS
+ return graph
+
+
+class TestReleasingTheAxes:
+ """The release follows the frame that states the locks, and waits on no frame to do it."""
+
+ def test_the_release_waits_on_no_frame(self) -> None:
+ graph = _graph()
+
+ with (
+ patch(f"{MODULE}.FrameCallbackManager"),
+ patch.object(graph_module.dpg, "split_frame") as split_frame,
+ ):
+ graph._release_axes_limits()
+
+ split_frame.assert_not_called()
+
+ def test_the_release_is_scheduled_for_the_following_frame(self) -> None:
+ graph = _graph()
+
+ with (
+ patch(f"{MODULE}.FrameCallbackManager") as frame,
+ patch.object(graph_module.dpg, "set_axis_limits_auto") as set_auto,
+ ):
+ graph._release_axes_limits()
+ set_auto.assert_not_called()
+ frame.set_frame_callback.assert_called_once_with(graph._set_axes_auto)
+ frame.set_frame_callback.call_args.args[0]()
+
+ assert [call.args[0] for call in set_auto.call_args_list] == [X_AXIS, Y_AXIS]
diff --git a/tests/unit/sampletones_application/ui/elements/graphs/test_waveform.py b/tests/unit/sampletones_application/ui/elements/graphs/test_waveform.py
index ce2de5541..2b51775f9 100644
--- a/tests/unit/sampletones_application/ui/elements/graphs/test_waveform.py
+++ b/tests/unit/sampletones_application/ui/elements/graphs/test_waveform.py
@@ -103,6 +103,8 @@ def _graph() -> GUIWaveformGraph:
graph._lbl_waveform_reconstruction = "Reconstruction"
graph._status_bar = MagicMock()
graph._msg_regenerating = "Regenerating reconstruction..."
+ graph.tag = "waveform"
+ graph._series_themes = {}
return graph
@@ -149,7 +151,7 @@ def test_series_color_is_untouched_when_not_dimmed(self) -> None:
assert graph._series_color(layer, graph._series_shade(layer)) == layer.color
- def test_series_color_greys_the_reconstruction_when_dimmed(self) -> None:
+ def test_series_color_grays_the_reconstruction_when_dimmed(self) -> None:
graph = _graph()
_with_layout(graph, opacity=0.4)
graph._reconstruction_dimmed = True
diff --git a/tests/unit/sampletones_application/ui/elements/graphs/test_waveform_colors.py b/tests/unit/sampletones_application/ui/elements/graphs/test_waveform_colors.py
new file mode 100644
index 000000000..734c116d5
--- /dev/null
+++ b/tests/unit/sampletones_application/ui/elements/graphs/test_waveform_colors.py
@@ -0,0 +1,81 @@
+from typing import Final, Generator, List
+from unittest.mock import MagicMock
+
+import dearpygui.dearpygui as dpg
+import numpy as np
+import pytest
+
+from sampletones_application.categories.manager import LanguageManager
+from sampletones_application.layout.config import LayoutConfig
+from sampletones_application.layout.loader import load_layout_config
+from sampletones_application.paths import (
+ BEHAVIOR_DIRECTORY,
+ LANG_EN,
+ LAYOUT_DIRECTORY,
+ PALETTES_DIRECTORY,
+ THEME_DIRECTORY,
+)
+from sampletones_application.ui.elements.fonts.registry import FontRegistry
+from sampletones_application.ui.elements.graphs.waveform import GUIWaveformGraph
+from sampletones_application.ui.themes.setup import setup_themes
+from sampletones_application.utils.palette.catalog import PaletteCatalog
+from sampletones_application.utils.palette.colors.written import LiteralColor
+from sampletones_application.utils.palette.source import PaletteSource
+from sampletones_shared.types.application import ColorRGBA
+
+PULSE: Final[ColorRGBA] = (240, 146, 86, 255)
+TRIANGLE: Final[ColorRGBA] = (140, 193, 237, 255)
+NOISE: Final[ColorRGBA] = (187, 184, 194, 255)
+
+VOICE_NAME: Final[str] = "lead"
+AUDIO: Final[np.ndarray] = np.zeros(8)
+
+
+@pytest.fixture
+def graph() -> Generator[GUIWaveformGraph, None, None]:
+ """A waveform graph on a live DearPyGui context, which is what holds the series themes."""
+ source = PaletteSource(PaletteCatalog.load(PALETTES_DIRECTORY).default)
+ layout: LayoutConfig = load_layout_config(LAYOUT_DIRECTORY, BEHAVIOR_DIRECTORY, source)
+ dpg.create_context()
+ try:
+ setup_themes(THEME_DIRECTORY, source)
+ FontRegistry.setup(layout.fonts)
+ FontRegistry.register_fonts()
+ with dpg.window(tag="root"):
+ yield GUIWaveformGraph(
+ tag="waveform",
+ parent="root",
+ layout=layout.graphs,
+ language_manager=LanguageManager(LANG_EN),
+ status_bar=MagicMock(),
+ )
+ finally:
+ dpg.destroy_context()
+
+
+def _drawn_color(graph: GUIWaveformGraph) -> ColorRGBA:
+ """The color DearPyGui holds on the one series the graph is showing."""
+ layer = next(iter(graph.layers.values()))
+ theme = dpg.get_item_theme(graph._series_tag(layer.name))
+ component = dpg.get_item_children(theme, 1)[0]
+ return tuple(round(value) for value in dpg.get_value(dpg.get_item_children(component, 1)[0]))
+
+
+class TestASeriesTakesTheColorItWasGiven:
+ def test_a_voice_reloaded_in_another_color_is_drawn_in_that_color(
+ self,
+ graph: GUIWaveformGraph,
+ ) -> None:
+ """A layer keeps its name across loads, so the theme follows the color, not the name."""
+ drawn: List[ColorRGBA] = []
+ for color in (PULSE, TRIANGLE, NOISE):
+ graph.load_voice_waveform(AUDIO, name=VOICE_NAME, color=LiteralColor(color))
+ drawn.append(_drawn_color(graph))
+
+ assert drawn == [PULSE, TRIANGLE, NOISE]
+
+ def test_a_color_shown_twice_is_built_once(self, graph: GUIWaveformGraph) -> None:
+ for color in (PULSE, TRIANGLE, PULSE):
+ graph.load_voice_waveform(AUDIO, name=VOICE_NAME, color=LiteralColor(color))
+
+ assert len(graph._series_themes) == 2
diff --git a/tests/unit/sampletones_application/ui/elements/layout/test_responsive.py b/tests/unit/sampletones_application/ui/elements/layout/test_responsive.py
index 9f1f0fb17..9732e0e81 100644
--- a/tests/unit/sampletones_application/ui/elements/layout/test_responsive.py
+++ b/tests/unit/sampletones_application/ui/elements/layout/test_responsive.py
@@ -127,7 +127,7 @@ def test_stays_between_the_base_and_the_cap(
class TestExpandedSideWidth(BaseTestSuite):
"""``expanded_side_width`` holds a fixed side column at its configured width up to the design
- baseline, then grants it one share of the wider viewport's surplus against the stretching centre
+ baseline, then grants it one share of the wider viewport's surplus against the stretching center
column's ``center_weight`` shares."""
@dataclass(frozen=True, kw_only=True)
@@ -168,7 +168,7 @@ class SideWidthCase(BaseRegularTestCase):
expected=400,
),
SideWidthCase(
- label="two_sides_split_after_centre",
+ label="two_sides_split_after_center",
base_width=300,
viewport_width=1600,
baseline_viewport_width=1280,
@@ -177,7 +177,7 @@ class SideWidthCase(BaseRegularTestCase):
expected=380,
),
SideWidthCase(
- label="heavier_centre_narrows_sides",
+ label="heavier_center_narrows_sides",
base_width=300,
viewport_width=1600,
baseline_viewport_width=1280,
diff --git a/tests/unit/sampletones_application/ui/elements/stems/__init__.py b/tests/unit/sampletones_application/ui/elements/stems/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/tests/unit/sampletones_application/ui/elements/stems/test_list.py b/tests/unit/sampletones_application/ui/elements/stems/test_list.py
new file mode 100644
index 000000000..5b41f9e28
--- /dev/null
+++ b/tests/unit/sampletones_application/ui/elements/stems/test_list.py
@@ -0,0 +1,565 @@
+from pathlib import Path
+from typing import Final, FrozenSet, Iterator, List, Tuple
+
+import dearpygui.dearpygui as dpg
+import pytest
+
+from sampletones_application.categories.manager import LanguageManager
+from sampletones_application.layout.config import LayoutConfig
+from sampletones_application.layout.loader import load_layout_config
+from sampletones_application.paths import (
+ BEHAVIOR_DIRECTORY,
+ LANG_EN,
+ LAYOUT_DIRECTORY,
+ PALETTES_DIRECTORY,
+ THEME_DIRECTORY,
+)
+from sampletones_application.tags.compose import compose_tag
+from sampletones_application.tags.general import (
+ SUF_BUTTON,
+ SUF_CHANNELS,
+ SUF_CHECKBOX,
+ SUF_HANDLER_REGISTRY,
+ SUF_LEVEL,
+ SUF_ROW,
+ SUF_STRIP,
+ SUF_TEXT,
+ TAG_GLOBAL_THEME_CHANNEL_MUTED,
+ TAG_GLOBAL_THEME_STEMS_ROW,
+ TAG_GLOBAL_THEME_STEMS_ROW_INERT,
+)
+from sampletones_application.ui.elements.fonts.registry import FontRegistry
+from sampletones_application.ui.elements.status import GUIStatusBar
+from sampletones_application.ui.elements.stems.list import GUIStemsList
+from sampletones_application.ui.themes.registry import ThemeRegistry
+from sampletones_application.ui.themes.setup import setup_themes
+from sampletones_application.utils.palette.catalog import PaletteCatalog
+from sampletones_application.utils.palette.source import PaletteSource
+from sampletones_application.view_model.shared.stems import (
+ StemRowViewModel,
+ StemsListViewModel,
+)
+from sampletones_core.constants.enums import ChannelName
+from sampletones_shared.types.callback import Callback
+
+ROOT_TAG = "test_root"
+PREFIX = "test.stems"
+CHANNELS: Tuple[ChannelName, ...] = (ChannelName.PULSE1, ChannelName.TRIANGLE)
+DRAG_PAYLOAD_SLOT: Final[int] = 3
+
+
+@pytest.fixture
+def layout_config() -> LayoutConfig:
+ source = PaletteSource(PaletteCatalog.load(PALETTES_DIRECTORY).default)
+ return load_layout_config(LAYOUT_DIRECTORY, BEHAVIOR_DIRECTORY, source)
+
+
+@pytest.fixture
+def dpg_context(layout_config: LayoutConfig) -> Iterator[None]:
+ """Stands up the context, fonts and themes the list binds while it draws."""
+ dpg.create_context()
+ FontRegistry.setup(layout_config.fonts)
+ FontRegistry.register_fonts(layout_config.fonts.scale)
+ setup_themes(THEME_DIRECTORY, PaletteSource(PaletteCatalog.load(PALETTES_DIRECTORY).default))
+ try:
+ yield
+ finally:
+ ThemeRegistry.clear()
+ dpg.destroy_context()
+
+
+def build(
+ layout_config: LayoutConfig,
+ *,
+ draggable: bool = True,
+ removable: bool = True,
+ retain_last_row: bool = False,
+ master_checkbox: bool = False,
+) -> GUIStemsList:
+ stems_list = GUIStemsList(
+ prefix=PREFIX,
+ layout=layout_config.general.stems,
+ language_manager=LanguageManager(LANG_EN),
+ status_bar=GUIStatusBar(),
+ draggable=draggable,
+ removable=removable,
+ retain_last_row=retain_last_row,
+ master_checkbox=master_checkbox,
+ )
+ with dpg.window(tag=ROOT_TAG):
+ stems_list.create(ROOT_TAG)
+
+ return stems_list
+
+
+def row(
+ name: str,
+ *,
+ channels: FrozenSet[ChannelName] = frozenset(CHANNELS),
+ offered_channels: FrozenSet[ChannelName] = frozenset(CHANNELS),
+ available: bool = True,
+ level: int = 0,
+ position: int = 0,
+ level_size: int = 1,
+ level_count: int = 1,
+) -> StemRowViewModel:
+ path = Path(f"/audio/{name}.wav")
+ return StemRowViewModel(
+ key=str(path),
+ path=path,
+ channels=channels,
+ offered_channels=offered_channels,
+ available=available,
+ level=level,
+ position=position,
+ level_size=level_size,
+ level_count=level_count,
+ )
+
+
+def view(
+ *rows: StemRowViewModel,
+ live: bool = True,
+ muted_channels: FrozenSet[ChannelName] = frozenset(),
+ collapse_levels: bool = False,
+) -> StemsListViewModel:
+ return StemsListViewModel(
+ rows=rows,
+ channels_in_play=CHANNELS,
+ muted_channels=muted_channels,
+ live=live,
+ collapse_levels=collapse_levels,
+ )
+
+
+def row_tag(entry: StemRowViewModel, suffix: str) -> str:
+ return compose_tag(PREFIX, SUF_ROW, entry.key, suffix)
+
+
+def channel_tag(entry: StemRowViewModel, channel_name: ChannelName) -> str:
+ return compose_tag(PREFIX, SUF_ROW, entry.key, SUF_CHANNELS, compose_tag(channel_name, SUF_CHECKBOX))
+
+
+def hover_handler(suffix: str) -> Callback:
+ """The hover callback a row widget of that kind shares, as DearPyGui would call it."""
+ registry = compose_tag(PREFIX, suffix, SUF_HANDLER_REGISTRY)
+ return dpg.get_item_callback(dpg.get_item_children(registry, 1)[-1])
+
+
+class TestRows:
+ def test_a_row_names_its_recording_and_offers_every_channel_in_play(self, dpg_context: None, layout_config) -> None:
+ stems_list = build(layout_config)
+ bass = row("bass")
+
+ stems_list.update_view(view(bass))
+
+ assert dpg.get_item_label(row_tag(bass, SUF_TEXT)) == "bass"
+ for channel_name in CHANNELS:
+ assert dpg.get_value(channel_tag(bass, channel_name))
+
+ def test_a_channel_the_row_lacks_reads_unticked(self, dpg_context: None, layout_config) -> None:
+ stems_list = build(layout_config)
+ bass = row("bass", channels=frozenset({ChannelName.PULSE1}))
+
+ stems_list.update_view(view(bass))
+
+ assert dpg.get_value(channel_tag(bass, ChannelName.PULSE1))
+ assert not dpg.get_value(channel_tag(bass, ChannelName.TRIANGLE))
+
+ def test_a_row_holding_no_channel_grays_out_and_still_answers(
+ self,
+ dpg_context: None,
+ layout_config,
+ ) -> None:
+ stems_list = build(layout_config)
+ bass = row("bass", channels=frozenset())
+
+ stems_list.update_view(view(bass))
+
+ name_tag = row_tag(bass, SUF_TEXT)
+ assert dpg.get_item_alias(dpg.get_item_theme(name_tag)) == TAG_GLOBAL_THEME_STEMS_ROW_INERT
+ assert dpg.is_item_enabled(name_tag)
+
+ def test_a_row_taking_part_reads_in_full(self, dpg_context: None, layout_config) -> None:
+ stems_list = build(layout_config)
+ bass = row("bass")
+
+ stems_list.update_view(view(bass))
+
+ assert dpg.get_item_alias(dpg.get_item_theme(row_tag(bass, SUF_TEXT))) == TAG_GLOBAL_THEME_STEMS_ROW
+
+ def test_rows_follow_a_changed_list(self, dpg_context: None, layout_config) -> None:
+ stems_list = build(layout_config)
+ bass, lead = row("bass"), row("lead")
+ stems_list.update_view(view(bass, lead, live=True))
+
+ stems_list.update_view(view(lead))
+
+ assert not dpg.does_item_exist(row_tag(bass, SUF_TEXT))
+ assert dpg.does_item_exist(row_tag(lead, SUF_TEXT))
+
+ def test_the_list_reports_the_row_a_gesture_named(self, dpg_context: None, layout_config) -> None:
+ stems_list = build(layout_config)
+ bass = row("bass")
+
+ stems_list.update_view(view(bass))
+
+ assert stems_list.row(bass.key) == bass
+ assert stems_list.row("nothing") is None
+
+
+class TestLevels:
+ def test_each_level_carries_its_own_band(self, dpg_context: None, layout_config) -> None:
+ stems_list = build(layout_config)
+ rows = (
+ row("bass", level=0, level_count=2),
+ row("drums", level=1, level_count=2),
+ )
+
+ stems_list.update_view(view(*rows))
+
+ assert dpg.get_value(compose_tag(PREFIX, SUF_LEVEL, "0", SUF_TEXT)) == "LEVEL 1"
+ assert dpg.get_value(compose_tag(PREFIX, SUF_LEVEL, "1", SUF_TEXT)) == "LEVEL 2"
+
+ def test_a_draggable_list_opens_a_strip_above_each_level_and_below_the_last(
+ self, dpg_context: None, layout_config
+ ) -> None:
+ stems_list = build(layout_config)
+ rows = (
+ row("bass", level=0, level_count=2),
+ row("drums", level=1, level_count=2),
+ )
+
+ stems_list.update_view(view(*rows))
+
+ for position in range(3):
+ assert dpg.does_item_exist(compose_tag(PREFIX, SUF_LEVEL, str(position), SUF_STRIP))
+
+
+class TestAffordances:
+ def test_a_draggable_list_makes_the_row_itself_the_thing_you_drag(
+ self,
+ dpg_context: None,
+ layout_config,
+ ) -> None:
+ stems_list = build(layout_config, draggable=True)
+ bass = row("bass")
+
+ stems_list.update_view(view(bass))
+
+ assert dpg.get_item_children(row_tag(bass, SUF_TEXT), DRAG_PAYLOAD_SLOT)
+
+ def test_a_list_without_dragging_carries_no_payload_and_no_strip(
+ self,
+ dpg_context: None,
+ layout_config,
+ ) -> None:
+ stems_list = build(layout_config, draggable=False)
+ bass = row("bass")
+
+ stems_list.update_view(view(bass))
+
+ assert not dpg.get_item_children(row_tag(bass, SUF_TEXT), DRAG_PAYLOAD_SLOT)
+ assert not dpg.does_item_exist(compose_tag(PREFIX, SUF_LEVEL, "0", SUF_STRIP))
+
+ def test_a_removable_list_gives_each_row_a_button(self, dpg_context: None, layout_config) -> None:
+ stems_list = build(layout_config, removable=True)
+ bass = row("bass")
+
+ stems_list.update_view(view(bass))
+
+ assert dpg.does_item_exist(row_tag(bass, SUF_BUTTON))
+
+ def test_a_list_without_removal_gives_no_button(self, dpg_context: None, layout_config) -> None:
+ stems_list = build(layout_config, removable=False)
+ bass = row("bass")
+
+ stems_list.update_view(view(bass))
+
+ assert not dpg.does_item_exist(row_tag(bass, SUF_BUTTON))
+
+
+class TestRetainedLastRow:
+ def test_a_list_holding_on_to_its_last_row_offers_no_way_to_remove_it(
+ self,
+ dpg_context: None,
+ layout_config,
+ ) -> None:
+ stems_list = build(layout_config, retain_last_row=True)
+ bass = row("bass")
+
+ stems_list.update_view(view(bass))
+
+ assert not dpg.is_item_enabled(row_tag(bass, SUF_BUTTON))
+
+ def test_a_row_may_leave_once_another_stands_beside_it(self, dpg_context: None, layout_config) -> None:
+ stems_list = build(layout_config, retain_last_row=True)
+ bass = row("bass")
+ lead = row("lead")
+
+ stems_list.update_view(view(bass, lead))
+
+ assert dpg.is_item_enabled(row_tag(bass, SUF_BUTTON))
+
+ def test_the_last_row_left_standing_stops_answering(self, dpg_context: None, layout_config) -> None:
+ stems_list = build(layout_config, retain_last_row=True)
+ bass = row("bass")
+ lead = row("lead")
+ stems_list.update_view(view(bass, lead))
+
+ stems_list.update_view(view(bass))
+
+ assert not dpg.is_item_enabled(row_tag(bass, SUF_BUTTON))
+
+ def test_a_list_that_keeps_no_row_lets_the_last_one_go(self, dpg_context: None, layout_config) -> None:
+ stems_list = build(layout_config, retain_last_row=False)
+ bass = row("bass")
+
+ stems_list.update_view(view(bass))
+
+ assert dpg.is_item_enabled(row_tag(bass, SUF_BUTTON))
+
+
+class TestGestures:
+ def test_unticking_a_channel_reports_the_row_and_what_it_keeps(self, dpg_context: None, layout_config) -> None:
+ reported: List[Tuple[str, FrozenSet[ChannelName]]] = []
+ stems_list = build(layout_config)
+ stems_list.on_channels_changed = lambda key, channels: reported.append((key, channels))
+ bass = row("bass")
+ stems_list.update_view(view(bass))
+
+ tag = channel_tag(bass, ChannelName.TRIANGLE)
+ dpg.set_value(tag, False)
+ dpg.get_item_callback(tag)(tag, False, dpg.get_item_user_data(tag))
+
+ assert reported == [(bass.key, frozenset({ChannelName.PULSE1}))]
+
+ def test_the_remove_button_reports_its_row(self, dpg_context: None, layout_config) -> None:
+ removed: List[str] = []
+ stems_list = build(layout_config)
+ stems_list.on_remove_requested = removed.append
+ bass = row("bass")
+ stems_list.update_view(view(bass))
+
+ tag = row_tag(bass, SUF_BUTTON)
+ dpg.get_item_callback(tag)(tag, None, dpg.get_item_user_data(tag))
+
+ assert removed == [bass.key]
+
+
+class TestBusyState:
+ def test_a_list_that_is_not_live_disables_every_control_it_drew(self, dpg_context: None, layout_config) -> None:
+ stems_list = build(layout_config)
+ bass = row("bass")
+
+ stems_list.update_view(view(bass, live=False))
+
+ assert not dpg.is_item_enabled(row_tag(bass, SUF_TEXT))
+ assert not dpg.is_item_enabled(row_tag(bass, SUF_BUTTON))
+ for channel_name in CHANNELS:
+ assert not dpg.is_item_enabled(channel_tag(bass, channel_name))
+
+ def test_a_live_list_answers_again(self, dpg_context: None, layout_config) -> None:
+ stems_list = build(layout_config)
+ bass = row("bass")
+ stems_list.update_view(view(bass, live=False))
+
+ stems_list.update_view(view(bass, live=True))
+
+ assert dpg.is_item_enabled(row_tag(bass, SUF_TEXT))
+ assert dpg.is_item_enabled(row_tag(bass, SUF_BUTTON))
+
+
+class TestVanishedWidgets:
+ """DearPyGui reports a hover a frame after it happened, by which time the row may have gone."""
+
+ def test_a_hover_naming_a_row_that_went_is_let_be(self, dpg_context: None, layout_config) -> None:
+ stems_list = build(layout_config)
+ bass = row("bass")
+ stems_list.update_view(view(bass))
+ hovered = dpg.get_alias_id(row_tag(bass, SUF_TEXT))
+
+ stems_list.update_view(view())
+
+ hover_handler(SUF_TEXT)(0, hovered)
+
+ def test_unticking_the_last_channel_keeps_the_widget_the_pointer_is_over(
+ self,
+ dpg_context: None,
+ layout_config,
+ ) -> None:
+ """Graying a row is drawn onto the widgets it stands as, so the pointer keeps its box."""
+ stems_list = build(layout_config)
+ bass = row("bass")
+ stems_list.update_view(view(bass))
+ standing = dpg.get_alias_id(channel_tag(bass, ChannelName.PULSE1))
+
+ stems_list.update_view(view(row("bass", channels=frozenset())))
+
+ assert dpg.get_alias_id(channel_tag(bass, ChannelName.PULSE1)) == standing
+
+
+class TestOfferedChannels:
+ def test_a_row_draws_a_box_only_on_the_channels_it_offers(self, dpg_context: None, layout_config) -> None:
+ stems_list = build(layout_config)
+ bass = row("bass", channels=frozenset({ChannelName.PULSE1}), offered_channels=frozenset({ChannelName.PULSE1}))
+
+ stems_list.update_view(view(bass))
+
+ assert dpg.does_item_exist(channel_tag(bass, ChannelName.PULSE1))
+ assert not dpg.does_item_exist(channel_tag(bass, ChannelName.TRIANGLE))
+
+ def test_a_recording_missing_from_disk_grays_out(self, dpg_context: None, layout_config) -> None:
+ stems_list = build(layout_config)
+ bass = row("bass", available=False)
+
+ stems_list.update_view(view(bass))
+
+ assert dpg.get_item_theme(row_tag(bass, SUF_TEXT)) == ThemeRegistry.get(TAG_GLOBAL_THEME_STEMS_ROW_INERT).tag
+
+ def test_a_row_gaining_a_box_is_drawn_again(self, dpg_context: None, layout_config) -> None:
+ stems_list = build(layout_config)
+ stems_list.update_view(view(row("bass", offered_channels=frozenset({ChannelName.PULSE1}))))
+
+ bass = row("bass")
+ stems_list.update_view(view(bass))
+
+ assert dpg.does_item_exist(channel_tag(bass, ChannelName.TRIANGLE))
+
+
+class TestMasterCheckbox:
+ def test_a_master_box_reads_whether_the_row_holds_a_channel(self, dpg_context: None, layout_config) -> None:
+ stems_list = build(layout_config, master_checkbox=True)
+ playing = row("bass")
+ quiet = row("pad", channels=frozenset())
+
+ stems_list.update_view(view(playing, quiet))
+
+ assert dpg.get_value(row_tag(playing, SUF_CHECKBOX))
+ assert not dpg.get_value(row_tag(quiet, SUF_CHECKBOX))
+
+ def test_ticking_the_master_box_hands_the_row_every_channel_it_offers(
+ self,
+ dpg_context: None,
+ layout_config,
+ ) -> None:
+ reported: List[Tuple[str, FrozenSet[ChannelName]]] = []
+ stems_list = build(layout_config, master_checkbox=True)
+ stems_list.on_channels_changed = lambda key, channels: reported.append((key, channels))
+ bass = row("bass", channels=frozenset(), offered_channels=frozenset({ChannelName.PULSE1}))
+ stems_list.update_view(view(bass))
+
+ master_tag = row_tag(bass, SUF_CHECKBOX)
+ dpg.get_item_callback(master_tag)(master_tag, True, bass.key)
+
+ assert reported == [(bass.key, frozenset({ChannelName.PULSE1}))]
+
+ def test_unticking_the_master_box_takes_every_channel_away(self, dpg_context: None, layout_config) -> None:
+ reported: List[Tuple[str, FrozenSet[ChannelName]]] = []
+ stems_list = build(layout_config, master_checkbox=True)
+ stems_list.on_channels_changed = lambda key, channels: reported.append((key, channels))
+ bass = row("bass")
+ stems_list.update_view(view(bass))
+
+ master_tag = row_tag(bass, SUF_CHECKBOX)
+ dpg.get_item_callback(master_tag)(master_tag, False, bass.key)
+
+ assert reported == [(bass.key, frozenset())]
+
+ def test_a_row_offering_no_channel_has_nothing_for_its_master_box_to_do(
+ self,
+ dpg_context: None,
+ layout_config,
+ ) -> None:
+ stems_list = build(layout_config, master_checkbox=True)
+ silent = row("pad", channels=frozenset(), offered_channels=frozenset())
+
+ stems_list.update_view(view(silent))
+
+ assert not dpg.is_item_enabled(row_tag(silent, SUF_CHECKBOX))
+
+ def test_a_list_without_a_master_box_draws_none(self, dpg_context: None, layout_config) -> None:
+ stems_list = build(layout_config)
+ bass = row("bass")
+
+ stems_list.update_view(view(bass))
+
+ assert not dpg.does_item_exist(row_tag(bass, SUF_CHECKBOX))
+
+
+class TestMutedChannels:
+ def test_a_muted_channel_takes_the_muted_tone(self, dpg_context: None, layout_config) -> None:
+ stems_list = build(layout_config)
+ bass = row("bass")
+
+ stems_list.update_view(view(bass, muted_channels=frozenset({ChannelName.TRIANGLE})))
+
+ muted = ThemeRegistry.get(TAG_GLOBAL_THEME_CHANNEL_MUTED).tag
+ assert dpg.get_item_theme(channel_tag(bass, ChannelName.TRIANGLE)) == muted
+ assert dpg.get_item_theme(channel_tag(bass, ChannelName.PULSE1)) != muted
+
+ def test_a_muted_box_keeps_its_value_and_stays_clickable(self, dpg_context: None, layout_config) -> None:
+ stems_list = build(layout_config)
+ bass = row("bass")
+
+ stems_list.update_view(view(bass, muted_channels=frozenset(CHANNELS)))
+
+ for channel_name in CHANNELS:
+ assert dpg.get_value(channel_tag(bass, channel_name))
+ assert dpg.is_item_enabled(channel_tag(bass, channel_name))
+
+ def test_a_channel_switched_back_on_takes_its_own_color_again(self, dpg_context: None, layout_config) -> None:
+ stems_list = build(layout_config)
+ bass = row("bass")
+ stems_list.update_view(view(bass, muted_channels=frozenset({ChannelName.TRIANGLE})))
+
+ stems_list.update_view(view(bass))
+
+ muted = ThemeRegistry.get(TAG_GLOBAL_THEME_CHANNEL_MUTED).tag
+ assert dpg.get_item_theme(channel_tag(bass, ChannelName.TRIANGLE)) != muted
+
+
+class TestCollapsedLevels:
+ def test_collapsing_draws_every_row_in_one_table(self, dpg_context: None, layout_config) -> None:
+ stems_list = build(layout_config, draggable=False)
+ rows = (
+ row("bass", level=0, position=0, level_size=1, level_count=2),
+ row("pad", level=1, position=0, level_size=1, level_count=2),
+ )
+
+ stems_list.update_view(view(*rows, collapse_levels=True))
+
+ assert dpg.does_item_exist(stems_list.table_tag)
+ assert not dpg.does_item_exist(compose_tag(PREFIX, SUF_LEVEL, "0", SUF_TEXT))
+ for entry in rows:
+ assert dpg.does_item_exist(row_tag(entry, SUF_TEXT))
+
+ def test_expanding_brings_the_captions_back(self, dpg_context: None, layout_config) -> None:
+ stems_list = build(layout_config, draggable=False)
+ rows = (
+ row("bass", level=0, position=0, level_size=1, level_count=2),
+ row("pad", level=1, position=0, level_size=1, level_count=2),
+ )
+ stems_list.update_view(view(*rows, collapse_levels=True))
+
+ stems_list.update_view(view(*rows))
+
+ assert not dpg.does_item_exist(stems_list.table_tag)
+ assert dpg.does_item_exist(compose_tag(PREFIX, SUF_LEVEL, "0", SUF_TEXT))
+ assert dpg.does_item_exist(compose_tag(PREFIX, SUF_LEVEL, "1", SUF_TEXT))
+
+
+class TestActivation:
+ def test_a_clicked_row_reports_itself_and_stays_unselected(self, dpg_context: None, layout_config) -> None:
+ activated: List[str] = []
+ stems_list = build(layout_config, draggable=False)
+ stems_list.on_row_activated = activated.append
+ bass = row("bass")
+ stems_list.update_view(view(bass))
+
+ name_tag = row_tag(bass, SUF_TEXT)
+ dpg.set_value(name_tag, True)
+ dpg.get_item_callback(name_tag)(name_tag, True, bass.key)
+
+ assert activated == [bass.key]
+ assert not dpg.get_value(name_tag)
diff --git a/tests/unit/sampletones_application/ui/elements/test_menu_section.py b/tests/unit/sampletones_application/ui/elements/test_menu_section.py
new file mode 100644
index 000000000..19647fe4f
--- /dev/null
+++ b/tests/unit/sampletones_application/ui/elements/test_menu_section.py
@@ -0,0 +1,137 @@
+from contextlib import contextmanager
+from typing import Any, Callable, Dict, Iterator, List, Tuple
+
+import pytest
+
+from sampletones_application.ui.elements import menu_section as menu_section_module
+from sampletones_application.ui.elements.menu_section import MenuSection
+
+MENU_TAG = "menu"
+MARKER_TAG = "menu_marker"
+
+
+class _DearPyGuiRecorder:
+ """Stands in for the framework, recording what a section states and takes away."""
+
+ def __init__(self) -> None:
+ self.items: List[Dict[str, Any]] = []
+ self.built: List[str] = []
+ self.containers: List[str] = []
+ self.deleted: List[int] = []
+ self.bound: List[Tuple[str, str]] = []
+
+ def add_group(self, *, tag: str) -> int:
+ self.built.append(f"group:{tag}")
+ return 0
+
+ @contextmanager
+ def item_handler_registry(self, **_kwargs: Any) -> Iterator[int]:
+ yield 0
+
+ def add_item_visible_handler(self, **_kwargs: Any) -> int:
+ return 0
+
+ def bind_item_handler_registry(self, item: str, registry: str) -> None:
+ self.bound.append((item, registry))
+
+ def append_items(self, tag: str, build: Callable[[], None]) -> Tuple[int, ...]:
+ self.containers.append(tag)
+ standing = len(self.items)
+ build()
+ return tuple(range(standing, len(self.items)))
+
+ def add_item(self) -> int:
+ self.items.append({})
+ return 0
+
+ def delete_item(self, item: int) -> None:
+ self.deleted.append(item)
+
+
+@pytest.fixture
+def framework(monkeypatch: pytest.MonkeyPatch) -> _DearPyGuiRecorder:
+ instance = _DearPyGuiRecorder()
+ monkeypatch.setattr(menu_section_module.dpg, "add_group", instance.add_group)
+ monkeypatch.setattr(menu_section_module.dpg, "item_handler_registry", instance.item_handler_registry)
+ monkeypatch.setattr(menu_section_module.dpg, "add_item_visible_handler", instance.add_item_visible_handler)
+ monkeypatch.setattr(menu_section_module.dpg, "bind_item_handler_registry", instance.bind_item_handler_registry)
+ monkeypatch.setattr(menu_section_module, "dpg_append_items", instance.append_items)
+ monkeypatch.setattr(menu_section_module, "dpg_delete_item", instance.delete_item)
+ return instance
+
+
+def _section(
+ build: Callable[[], None],
+ *,
+ menu_tag: str = MENU_TAG,
+) -> MenuSection:
+ return MenuSection(menu_tag=menu_tag, marker_tag=MARKER_TAG, build=build)
+
+
+class TestSectionPlacement:
+ def test_the_items_are_stated_into_the_menu_itself(
+ self,
+ framework: _DearPyGuiRecorder,
+ ) -> None:
+ """A section belongs to one menu, so its items land in that menu and nowhere else."""
+ _section(framework.add_item).refresh()
+
+ assert framework.containers == [MENU_TAG]
+
+ def test_the_marker_is_what_the_section_is_reported_by(
+ self,
+ framework: _DearPyGuiRecorder,
+ ) -> None:
+ section = _section(framework.add_item)
+ section.add_marker()
+ section.watch()
+
+ assert framework.built[0] == f"group:{MARKER_TAG}"
+ assert [item for item, _ in framework.bound] == [MARKER_TAG]
+
+ def test_a_build_takes_away_only_what_the_one_before_it_stated(
+ self,
+ framework: _DearPyGuiRecorder,
+ ) -> None:
+ """The menu's own items stand where they are, since only the last build is taken away."""
+ section = _section(framework.add_item)
+
+ section.refresh()
+ section.refresh()
+
+ assert framework.deleted == [0]
+
+
+class TestSectionRefresh:
+ """DearPyGui reports the marker drawn once a frame while the menu stands open, so a gap in
+ those reports is what marks a fresh opening."""
+
+ def test_the_items_are_stated_once_while_the_menu_stays_open(
+ self,
+ framework: _DearPyGuiRecorder,
+ monkeypatch: pytest.MonkeyPatch,
+ ) -> None:
+ frames = iter([10, 11, 12, 13])
+ monkeypatch.setattr(menu_section_module.dpg, "get_frame_count", lambda: next(frames))
+ requests: List[int] = []
+ section = _section(lambda: requests.append(1))
+
+ for _ in range(4):
+ section._on_marker_drawn(0, 0)
+
+ assert len(requests) == 1
+
+ def test_the_items_are_stated_afresh_each_time_the_menu_is_opened(
+ self,
+ framework: _DearPyGuiRecorder,
+ monkeypatch: pytest.MonkeyPatch,
+ ) -> None:
+ frames = iter([10, 11, 40, 41])
+ monkeypatch.setattr(menu_section_module.dpg, "get_frame_count", lambda: next(frames))
+ requests: List[int] = []
+ section = _section(lambda: requests.append(1))
+
+ for _ in range(4):
+ section._on_marker_drawn(0, 0)
+
+ assert len(requests) == 2
diff --git a/tests/unit/sampletones_application/ui/elements/test_window.py b/tests/unit/sampletones_application/ui/elements/test_window.py
index 127570864..2d3cf79cd 100644
--- a/tests/unit/sampletones_application/ui/elements/test_window.py
+++ b/tests/unit/sampletones_application/ui/elements/test_window.py
@@ -116,3 +116,42 @@ def test_the_widget_tree_survives_the_hand_off(self, dpg_context: None) -> None:
window.yield_to(MagicMock())
assert dpg.get_item_children(TAG, 1)
+
+
+class TestRaisingAWindow:
+ """A window is raised from wherever a result reaches the screen, the callback drain between
+ frames included, so opening one waits on no frame."""
+
+ def test_opening_waits_on_no_frame(self, dpg_context: None) -> None:
+ window = ProbeWindow(on_close=None)
+
+ with (
+ patch(f"{MODULE}.ThemeRegistry"),
+ patch(f"{MODULE}.center_when_settled"),
+ patch.object(dpg, "split_frame") as split_frame,
+ ):
+ window.show()
+
+ split_frame.assert_not_called()
+
+ def test_opening_centers_the_window_once_it_has_been_measured(self, dpg_context: None) -> None:
+ window = ProbeWindow(on_close=None)
+
+ with (
+ patch(f"{MODULE}.ThemeRegistry"),
+ patch(f"{MODULE}.center_when_settled") as center_when_settled,
+ ):
+ window.show()
+
+ center_when_settled.assert_called_once_with(TAG)
+
+ def test_opening_builds_the_tree(self, dpg_context: None) -> None:
+ window = ProbeWindow(on_close=None)
+
+ with (
+ patch(f"{MODULE}.ThemeRegistry"),
+ patch(f"{MODULE}.center_when_settled"),
+ ):
+ window.show()
+
+ assert dpg.get_item_children(TAG, 1)
diff --git a/tests/unit/sampletones_application/ui/elements/tree/test_detail_items.py b/tests/unit/sampletones_application/ui/elements/tree/test_detail_items.py
index 3cae480ae..e92e356ee 100644
--- a/tests/unit/sampletones_application/ui/elements/tree/test_detail_items.py
+++ b/tests/unit/sampletones_application/ui/elements/tree/test_detail_items.py
@@ -28,7 +28,7 @@
"spectrum_method",
"transformation_gamma",
"window_size",
- "generators",
+ "channels",
"configuration",
]
diff --git a/tests/unit/sampletones_application/ui/elements/tree/test_favorites_filter.py b/tests/unit/sampletones_application/ui/elements/tree/test_favorites_filter.py
index 2e34b4202..29d184b1e 100644
--- a/tests/unit/sampletones_application/ui/elements/tree/test_favorites_filter.py
+++ b/tests/unit/sampletones_application/ui/elements/tree/test_favorites_filter.py
@@ -633,7 +633,7 @@ def test_a_query_typed_earlier_survives_a_change_of_mode(
class TestStarColor:
- """The star beside the label reads in the colour of the mode it stands for."""
+ """The star beside the label reads in the color of the mode it stands for."""
def test_the_star_reads_favorite_while_the_mode_is_on(self, corpus: BrowserCorpus) -> None:
panel = build_browser_panel(corpus, set(), favorites_only=True)
@@ -643,24 +643,24 @@ def test_the_star_reads_muted_while_the_mode_is_off(self, corpus: BrowserCorpus)
panel = build_browser_panel(corpus, set(), favorites_only=False)
assert panel._favorites_glyph_color() == TREE_COLORS.muted
- def test_the_star_is_coloured_with_the_token_the_mode_names(
+ def test_the_star_is_colored_with_the_token_the_mode_names(
self,
corpus: BrowserCorpus,
monkeypatch: pytest.MonkeyPatch,
) -> None:
- """The colour reaches the star as a token, so the star follows a palette swapped in place."""
+ """The color reaches the star as a token, so the star follows a palette swapped in place."""
panel = build_browser_panel(corpus, set(), favorites_only=True)
panel._favorites_glyph_tag = GLYPH_TAG
- coloured: List[Tuple[str, BaseColor]] = []
+ colored: List[Tuple[str, BaseColor]] = []
monkeypatch.setattr(
tree_module,
"dpg_set_palette_color",
- lambda item, color: coloured.append((item, color)),
+ lambda item, color: colored.append((item, color)),
)
panel._apply_favorites_glyph_color()
- assert coloured == [(GLYPH_TAG, TREE_COLORS.favorite)]
+ assert colored == [(GLYPH_TAG, TREE_COLORS.favorite)]
class TestControlLock:
diff --git a/tests/unit/sampletones_application/ui/panels/dialogs/test_export.py b/tests/unit/sampletones_application/ui/panels/dialogs/test_export.py
new file mode 100644
index 000000000..47e40de3a
--- /dev/null
+++ b/tests/unit/sampletones_application/ui/panels/dialogs/test_export.py
@@ -0,0 +1,144 @@
+from typing import Final, List, Tuple
+
+import dearpygui.dearpygui as dpg
+import pytest
+
+from sampletones_application.categories.manager import LanguageManager
+from sampletones_application.layout.config import LayoutConfig
+from sampletones_application.paths import LANG_EN
+from sampletones_application.tags.compose import compose_tag
+from sampletones_application.tags.general import SUF_BUTTON
+from sampletones_application.tags.settings import (
+ TAG_SETTINGS_EXPORT_BUTTON_CANCEL,
+ TAG_SETTINGS_EXPORT_GROUP_MEASURED,
+ TAG_SETTINGS_EXPORT_GROUP_WORKING,
+ TAG_SETTINGS_EXPORT_PROGRESS,
+ TAG_SETTINGS_EXPORT_TEXT_FIGURE,
+ TAG_SETTINGS_EXPORT_TEXT_STAGE,
+)
+from sampletones_application.ui.panels.dialogs.export import GUIExportWindow
+from sampletones_application.utils.gui.keyboard import KeyRouter
+from sampletones_application.view_model.shared.export import (
+ ExportPhase,
+ SongExportViewModel,
+)
+from sampletones_core.exports.stage import ExportStage
+from tests.suite.shortcuts import shipped_source
+
+LANGUAGE_MANAGER: Final[LanguageManager] = LanguageManager(LANG_EN)
+HALFWAY: Final[float] = 0.5
+SIZE: Final[str] = "8761 of 32429 bytes"
+
+
+@pytest.fixture(name="window")
+def window_fixture(dpg_context: None, layout_config: LayoutConfig) -> GUIExportWindow:
+ return GUIExportWindow(
+ layout=layout_config.settings,
+ text_colors=layout_config.general.colors.text,
+ language_manager=LANGUAGE_MANAGER,
+ key_router=KeyRouter(),
+ shortcut_source=shipped_source(),
+ )
+
+
+def render(
+ window: GUIExportWindow,
+ *,
+ stages: Tuple[ExportStage, ...] = (ExportStage.WALKING,),
+ phase: ExportPhase = ExportPhase.EXPORTING,
+ figure: str = "",
+ progress: float = HALFWAY,
+ traveling: bool = True,
+) -> None:
+ """Builds the widget tree and draws the given state, the way an open window is kept up to date."""
+ window.create_window()
+ window.update_view(
+ SongExportViewModel(
+ phase=phase,
+ stages=stages,
+ figure=figure,
+ progress=progress,
+ traveling=traveling,
+ )
+ )
+
+
+def stage_tag(stage: ExportStage) -> str:
+ return compose_tag(TAG_SETTINGS_EXPORT_TEXT_STAGE, stage.value)
+
+
+def shown(tag: str) -> bool:
+ return bool(dpg.get_item_configuration(tag)["show"])
+
+
+class TestTheStageList:
+ """A stage appears once the run has reached it, and stays as the run moves on."""
+
+ def test_a_reached_stage_is_listed(self, window: GUIExportWindow) -> None:
+ render(window, stages=(ExportStage.WALKING,))
+ assert shown(stage_tag(ExportStage.WALKING))
+
+ def test_a_stage_the_run_never_reached_is_left_off(self, window: GUIExportWindow) -> None:
+ render(window, stages=(ExportStage.WALKING,))
+ assert not shown(stage_tag(ExportStage.WRITING))
+
+ def test_every_stage_reached_stays_on_the_list(self, window: GUIExportWindow) -> None:
+ render(window, stages=(ExportStage.WALKING, ExportStage.COMPRESSING, ExportStage.WRITING))
+ listed: List[ExportStage] = [stage for stage in ExportStage if shown(stage_tag(stage))]
+ assert listed == [ExportStage.WALKING, ExportStage.COMPRESSING, ExportStage.WRITING]
+
+ def test_the_reader_finds_each_stage_under_its_own_name(self, window: GUIExportWindow) -> None:
+ render(window, stages=(ExportStage.COMPRESSING,))
+ assert dpg.get_value(stage_tag(ExportStage.COMPRESSING)) == "Compressing the song"
+
+
+class TestHowTheStageUnderWayReads:
+ """A stage arriving at an end carries a bar; one measured against a limit carries a figure."""
+
+ def test_a_travelling_stage_shows_its_bar(self, window: GUIExportWindow) -> None:
+ render(window, traveling=True)
+ assert shown(TAG_SETTINGS_EXPORT_GROUP_MEASURED)
+ assert not shown(TAG_SETTINGS_EXPORT_GROUP_WORKING)
+
+ def test_a_bar_stands_where_the_stage_has_reached(self, window: GUIExportWindow) -> None:
+ render(window, traveling=True, progress=HALFWAY)
+ assert dpg.get_value(TAG_SETTINGS_EXPORT_PROGRESS) == pytest.approx(HALFWAY)
+
+ def test_a_bar_is_labeled_with_the_share_it_has_covered(self, window: GUIExportWindow) -> None:
+ render(window, traveling=True, progress=HALFWAY)
+ assert dpg.get_item_configuration(TAG_SETTINGS_EXPORT_PROGRESS)["overlay"] == "50%"
+
+ def test_a_stage_without_an_end_shows_what_it_holds(self, window: GUIExportWindow) -> None:
+ render(window, traveling=False, figure=SIZE)
+ assert shown(TAG_SETTINGS_EXPORT_GROUP_WORKING)
+ assert not shown(TAG_SETTINGS_EXPORT_GROUP_MEASURED)
+
+ def test_the_figure_reaches_the_reader(self, window: GUIExportWindow) -> None:
+ render(window, traveling=False, figure=SIZE)
+ assert dpg.get_value(TAG_SETTINGS_EXPORT_TEXT_FIGURE) == SIZE
+
+
+class TestStoppingARun:
+ """Cancel stands while the run can still answer it, and reports the ask once."""
+
+ def test_a_running_export_offers_a_stop(self, window: GUIExportWindow) -> None:
+ render(window, phase=ExportPhase.EXPORTING)
+ assert dpg.get_item_configuration(TAG_SETTINGS_EXPORT_BUTTON_CANCEL)["enabled"]
+
+ def test_an_export_already_stopping_offers_no_further_stop(self, window: GUIExportWindow) -> None:
+ render(window, phase=ExportPhase.CANCELLING)
+ assert not dpg.get_item_configuration(TAG_SETTINGS_EXPORT_BUTTON_CANCEL)["enabled"]
+
+ def test_pressing_cancel_asks_the_run_to_stop(self, window: GUIExportWindow) -> None:
+ asked: List[bool] = []
+ window.on_cancel = lambda: asked.append(True)
+ render(window, phase=ExportPhase.EXPORTING)
+ dpg.get_item_callback(compose_tag(TAG_SETTINGS_EXPORT_BUTTON_CANCEL, SUF_BUTTON))()
+ assert asked == [True]
+
+ def test_a_run_already_stopping_takes_no_second_ask(self, window: GUIExportWindow) -> None:
+ asked: List[bool] = []
+ window.on_cancel = lambda: asked.append(True)
+ render(window, phase=ExportPhase.CANCELLING)
+ dpg.get_item_callback(compose_tag(TAG_SETTINGS_EXPORT_BUTTON_CANCEL, SUF_BUTTON))()
+ assert asked == []
diff --git a/tests/unit/sampletones_application/ui/panels/dialogs/test_keybindings.py b/tests/unit/sampletones_application/ui/panels/dialogs/test_keybindings.py
index 1dfe4852b..f175443f9 100644
--- a/tests/unit/sampletones_application/ui/panels/dialogs/test_keybindings.py
+++ b/tests/unit/sampletones_application/ui/panels/dialogs/test_keybindings.py
@@ -312,7 +312,7 @@ def test_a_listening_cell_asks_for_the_press(self, harness: Harness) -> None:
assert harness.label_of(shortcut_tag(SAVE_PROJECT)) == CAPTURING_MESSAGE
- def test_a_cancelled_capture_leaves_the_cell_reading_its_keys(self, harness: Harness) -> None:
+ def test_a_canceled_capture_leaves_the_cell_reading_its_keys(self, harness: Harness) -> None:
harness.render(view_model(selected=SAVE_PROJECT))
harness.click_shortcut(SAVE_PROJECT)
harness.press(dpg.mvKey_Escape)
diff --git a/tests/unit/sampletones_application/ui/panels/dialogs/test_project_properties.py b/tests/unit/sampletones_application/ui/panels/dialogs/test_project_properties.py
index 0dd8e62f5..0fb034636 100644
--- a/tests/unit/sampletones_application/ui/panels/dialogs/test_project_properties.py
+++ b/tests/unit/sampletones_application/ui/panels/dialogs/test_project_properties.py
@@ -85,7 +85,7 @@ def test_the_info_shows_the_project_s_own(self, window: GUIProjectPropertiesWind
assert dpg.get_value(TAG_SETTINGS_PROPERTIES_INPUT_AUTHOR) == "Composer"
assert dpg.get_value(TAG_SETTINGS_PROPERTIES_INPUT_COMMENT) == "A note to self"
- def test_the_metre_shows_the_project_s_highlights(self, window: GUIProjectPropertiesWindow) -> None:
+ def test_the_meter_shows_the_project_s_highlights(self, window: GUIProjectPropertiesWindow) -> None:
render(window)
assert dpg.get_value(TAG_SETTINGS_PROPERTIES_INPUT_FIRST_HIGHLIGHT) == FIRST_HIGHLIGHT
@@ -124,7 +124,7 @@ def committed_fixture(self, window: GUIProjectPropertiesWindow) -> List[Committe
render(window)
return committed
- def test_the_edited_metre_reaches_the_owner(
+ def test_the_edited_meter_reaches_the_owner(
self,
window: GUIProjectPropertiesWindow,
committed: List[Committed],
@@ -149,7 +149,7 @@ def test_a_highlight_past_the_range_arrives_clamped(
assert committed[-1][3:] == (MAX_HIGHLIGHT, MIN_HIGHLIGHT)
- def test_the_metre_carries_the_info_with_it(
+ def test_the_meter_carries_the_info_with_it(
self,
window: GUIProjectPropertiesWindow,
committed: List[Committed],
diff --git a/tests/unit/sampletones_application/ui/panels/instruction/test_colors.py b/tests/unit/sampletones_application/ui/panels/instruction/test_colors.py
new file mode 100644
index 000000000..dd061ad81
--- /dev/null
+++ b/tests/unit/sampletones_application/ui/panels/instruction/test_colors.py
@@ -0,0 +1,83 @@
+from typing import Final
+
+import pytest
+
+from sampletones_application.layout.general.colors.channel import ChannelColors
+from sampletones_application.ui.panels.instruction.colors import fragment_color
+from sampletones_application.utils.palette.colors.written import LiteralColor, WrittenColor
+from sampletones_core.configs import Config
+from sampletones_core.constants.enums import GeneratorClassName
+from sampletones_core.fft import Window
+from sampletones_core.fft.features import get_feature_extractor
+from sampletones_core.generators import get_generators_map
+from sampletones_core.instructions import InstructionUnion
+from sampletones_core.instructions.implementation.noise import NoiseInstruction
+from sampletones_core.instructions.implementation.pulse import PulseInstruction
+from sampletones_core.instructions.implementation.triangle import TriangleInstruction
+from sampletones_core.library import InstructionLibraryFragment
+from sampletones_core.library.creator.creation import generate_instruction
+
+CHANNEL_COLORS: Final[ChannelColors] = ChannelColors(
+ pulse1=LiteralColor((240, 146, 86, 255)),
+ pulse2=LiteralColor((242, 209, 95, 255)),
+ triangle=LiteralColor((140, 193, 237, 255)),
+ noise=LiteralColor((187, 184, 194, 255)),
+)
+
+
+def _fragment(
+ generator_class: GeneratorClassName,
+ instruction: InstructionUnion,
+) -> InstructionLibraryFragment[InstructionUnion]:
+ """One fragment as the library makes it, which is what the Instructions tab draws."""
+ config = Config()
+ window = Window.from_config(config)
+ _, fragment = generate_instruction(
+ get_generators_map(config),
+ generator_class,
+ instruction,
+ get_feature_extractor(config, window),
+ )
+ return fragment
+
+
+class TestTheColorAFragmentIsDrawnIn:
+ @pytest.mark.parametrize(
+ ("generator_class", "instruction", "expected"),
+ [
+ (
+ GeneratorClassName.PULSE_GENERATOR,
+ PulseInstruction.default_instruction(),
+ CHANNEL_COLORS.pulse1,
+ ),
+ (
+ GeneratorClassName.TRIANGLE_GENERATOR,
+ TriangleInstruction.default_instruction(),
+ CHANNEL_COLORS.triangle,
+ ),
+ (
+ GeneratorClassName.NOISE_GENERATOR,
+ NoiseInstruction.default_instruction(),
+ CHANNEL_COLORS.noise,
+ ),
+ ],
+ ids=["pulse", "triangle", "noise"],
+ )
+ def test_a_fragment_takes_the_color_of_the_channel_its_generator_is_heard_on(
+ self,
+ generator_class: GeneratorClassName,
+ instruction: InstructionUnion,
+ expected: WrittenColor,
+ ) -> None:
+ assert fragment_color(CHANNEL_COLORS, _fragment(generator_class, instruction)) == expected
+
+ def test_the_three_generators_are_drawn_in_three_different_colors(self) -> None:
+ colors = {
+ fragment_color(CHANNEL_COLORS, _fragment(generator_class, instruction))
+ for generator_class, instruction in (
+ (GeneratorClassName.PULSE_GENERATOR, PulseInstruction.default_instruction()),
+ (GeneratorClassName.TRIANGLE_GENERATOR, TriangleInstruction.default_instruction()),
+ (GeneratorClassName.NOISE_GENERATOR, NoiseInstruction.default_instruction()),
+ )
+ }
+ assert len(colors) == 3
diff --git a/tests/unit/sampletones_application/ui/panels/instruction/test_library_generator_nodes.py b/tests/unit/sampletones_application/ui/panels/instruction/test_library_generator_nodes.py
new file mode 100644
index 000000000..fd561ebe0
--- /dev/null
+++ b/tests/unit/sampletones_application/ui/panels/instruction/test_library_generator_nodes.py
@@ -0,0 +1,76 @@
+from typing import Final, List, Tuple
+
+import dearpygui.dearpygui as dpg
+
+from sampletones_application.ui.panels.instruction.library import GUIInstructionsLibraryPanel
+from sampletones_core.constants.enums import GeneratorName
+from sampletones_core.library import InstructionLibraryKey
+from sampletones_core.structures.tree import GeneratorNode, LibraryNode
+from tests.suite.language import FakeLanguageManager
+
+STATUS_KEY: Final[str] = "instructions.library.message.status_node_generator"
+STATUS_TEXT: Final[str] = "{generator} of {library_key}"
+LIBRARY_FILENAME: Final[str] = "library_abc123"
+
+
+def _library_key() -> InstructionLibraryKey:
+ return InstructionLibraryKey(
+ sample_rate=44100,
+ frame_length=1470,
+ window_size=2940,
+ transformation_gamma=100,
+ config_hash="abc123",
+ filename=LIBRARY_FILENAME,
+ )
+
+
+def _generator_node() -> GeneratorNode:
+ library = LibraryNode("Library", library_key=_library_key())
+ return GeneratorNode("Pulse", generator_name=GeneratorName.PULSE, parent=library)
+
+
+def _panel() -> GUIInstructionsLibraryPanel:
+ panel = GUIInstructionsLibraryPanel.__new__(GUIInstructionsLibraryPanel)
+ panel._language_manager = FakeLanguageManager(texts={STATUS_KEY: STATUS_TEXT})
+ return panel
+
+
+class TestGeneratorNodeMessage:
+ def test_the_message_names_the_generator_and_its_library(self) -> None:
+ """Hovering a generator row says which generator of which library it stands for."""
+ panel = _panel()
+ message_function = panel._create_status_bar_message_function_for_instructions_node()
+
+ message = message_function(user_data=(_generator_node(), "row_tag"))
+
+ assert message == f"{GeneratorName.PULSE} of {LIBRARY_FILENAME}"
+
+
+class TestGeneratorNodeSelection:
+ def test_a_click_names_the_library_and_the_generator(self) -> None:
+ """Clicking a generator row hands on the library it belongs to and the generator it is."""
+ panel = _panel()
+ selected: List[Tuple[InstructionLibraryKey, GeneratorName]] = []
+ panel.on_generator_selected = lambda library_key, generator_name: selected.append(
+ (library_key, generator_name),
+ )
+
+ panel._on_generator_node_clicked(
+ None,
+ (dpg.mvMouseButton_Left, 0),
+ (_generator_node(), "row_tag"),
+ )
+
+ assert selected == [(_library_key(), GeneratorName.PULSE)]
+
+ def test_loading_from_the_row_menu_names_the_same_pair(self) -> None:
+ """The row menu's load item reaches the generator the row holds, as a click does."""
+ panel = _panel()
+ selected: List[Tuple[InstructionLibraryKey, GeneratorName]] = []
+ panel.on_generator_selected = lambda library_key, generator_name: selected.append(
+ (library_key, generator_name),
+ )
+
+ panel._on_load_generator(None, True, _generator_node())
+
+ assert selected == [(_library_key(), GeneratorName.PULSE)]
diff --git a/tests/unit/sampletones_application/ui/panels/main/test_explorer_controls.py b/tests/unit/sampletones_application/ui/panels/main/test_explorer_controls.py
index ddaf46487..6eb4b96b3 100644
--- a/tests/unit/sampletones_application/ui/panels/main/test_explorer_controls.py
+++ b/tests/unit/sampletones_application/ui/panels/main/test_explorer_controls.py
@@ -196,3 +196,64 @@ def test_the_folders_the_model_held_are_dropped_afterwards(
root = tree.get_root()
assert root is not None
assert [str(node.name) for node in root.descendants] == [str(ROOT)]
+
+
+class FakeAutoplayLogic:
+ """Answers the panel's request to preview a recording, recording what it was handed."""
+
+ def __init__(self) -> None:
+ self.played: List[FileSystemNode] = []
+
+ def request_autoplay(self, node: FileSystemNode) -> None:
+ self.played.append(node)
+
+
+class RecordingClick:
+ """A panel wired to record where a click on a recording went."""
+
+ def __init__(self, *, can_add_stems: bool) -> None:
+ tree = explorer_tree()
+ self.panel = build_panel(tree)
+ self.node = tree.find_nodes(FileSystemNode, lambda node: node.filepath == MUSIC / "song.wav")[0]
+ self.autoplay = FakeAutoplayLogic()
+ self.gathered: List[Path] = []
+ self.selected: List[Path] = []
+ self.panel._logic = self.autoplay # type: ignore[assignment]
+ self.panel.can_add_stems = lambda: can_add_stems
+ self.panel.on_file_add_requested = self.gathered.append
+ self.panel.on_wave_file_clicked = self.selected.append
+
+ def click(self, monkeypatch: pytest.MonkeyPatch, *, holding_ctrl: bool) -> None:
+ held = {explorer_module.Modifier.CTRL} if holding_ctrl else set()
+ monkeypatch.setattr(explorer_module, "capture_modifiers", lambda: frozenset(held))
+ self.panel._audio_node_clicked(self.node)
+
+
+class TestClickingARecording:
+ """Ctrl gathers a recording as a stem; a plain click hands it to the converter and plays it."""
+
+ def test_a_plain_click_selects_the_recording_and_plays_it(self, monkeypatch: pytest.MonkeyPatch) -> None:
+ clicked = RecordingClick(can_add_stems=True)
+
+ clicked.click(monkeypatch, holding_ctrl=False)
+
+ assert clicked.selected == [MUSIC / "song.wav"]
+ assert clicked.autoplay.played == [clicked.node]
+ assert clicked.gathered == []
+
+ def test_holding_ctrl_gathers_the_recording_as_a_stem(self, monkeypatch: pytest.MonkeyPatch) -> None:
+ clicked = RecordingClick(can_add_stems=True)
+
+ clicked.click(monkeypatch, holding_ctrl=True)
+
+ assert clicked.gathered == [MUSIC / "song.wav"]
+ assert clicked.selected == []
+ assert clicked.autoplay.played == []
+
+ def test_a_busy_converter_leaves_ctrl_the_plain_click(self, monkeypatch: pytest.MonkeyPatch) -> None:
+ clicked = RecordingClick(can_add_stems=False)
+
+ clicked.click(monkeypatch, holding_ctrl=True)
+
+ assert clicked.selected == [MUSIC / "song.wav"]
+ assert clicked.gathered == []
diff --git a/tests/unit/sampletones_application/ui/panels/main/test_reconstructor.py b/tests/unit/sampletones_application/ui/panels/main/test_reconstructor.py
index ae07e0514..9bb777781 100644
--- a/tests/unit/sampletones_application/ui/panels/main/test_reconstructor.py
+++ b/tests/unit/sampletones_application/ui/panels/main/test_reconstructor.py
@@ -7,13 +7,13 @@
from sampletones_application.ui.panels.main import reconstructor as reconstructor_module
from sampletones_application.ui.panels.main.reconstructor import GUIReconstructorPanel
from sampletones_application.view_model.main.updates import GenerationSettingsUpdate
-from sampletones_core.constants.enums import GeneratorName
+from sampletones_core.constants.enums import ChannelName
from tests.suite.base import BaseTestSuite
from tests.suite.case import BaseRegularTestCase
DRIVE = 1.5
-ALL_GENERATORS = frozenset(GeneratorName)
+ALL_CHANNELS = frozenset(ChannelName)
class Harness:
@@ -21,12 +21,11 @@ class Harness:
def __init__(
self,
- checked: FrozenSet[GeneratorName],
+ checked: FrozenSet[ChannelName],
monkeypatch: pytest.MonkeyPatch,
) -> None:
self.values: Dict[str, bool] = {
- GUIReconstructorPanel._get_generator_checkbox_tag(generator): generator in checked
- for generator in GeneratorName
+ GUIReconstructorPanel._get_generator_checkbox_tag(channel): channel in checked for channel in ChannelName
}
self.reported: List[GenerationSettingsUpdate] = []
@@ -42,40 +41,40 @@ def _drive(tag: str) -> float:
assert tag == TAG_MAIN_RECONSTRUCTOR_SLIDER_DRIVE
return DRIVE
- def checked(self) -> FrozenSet[GeneratorName]:
+ def checked(self) -> FrozenSet[ChannelName]:
return frozenset(
- generator
- for generator in GeneratorName
- if self.values[GUIReconstructorPanel._get_generator_checkbox_tag(generator)]
+ channel
+ for channel in ChannelName
+ if self.values[GUIReconstructorPanel._get_generator_checkbox_tag(channel)]
)
-class TestToggleGenerator(BaseTestSuite):
+class TestToggleChannel(BaseTestSuite):
"""The key a channel answers to switches its checkbox, the gesture a click on it makes."""
@dataclass(frozen=True, kw_only=True)
class TestCase(BaseRegularTestCase):
- checked: FrozenSet[GeneratorName]
- generator: GeneratorName
- expected: FrozenSet[GeneratorName]
+ checked: FrozenSet[ChannelName]
+ channel: ChannelName
+ expected: FrozenSet[ChannelName]
test_cases = (
TestCase(
label="switching one off leaves the rest",
- checked=ALL_GENERATORS,
- generator=GeneratorName.TRIANGLE,
- expected=ALL_GENERATORS - {GeneratorName.TRIANGLE},
+ checked=ALL_CHANNELS,
+ channel=ChannelName.TRIANGLE,
+ expected=ALL_CHANNELS - {ChannelName.TRIANGLE},
),
TestCase(
label="switching one on adds it alone",
checked=frozenset(),
- generator=GeneratorName.PULSE1,
- expected=frozenset({GeneratorName.PULSE1}),
+ channel=ChannelName.PULSE1,
+ expected=frozenset({ChannelName.PULSE1}),
),
TestCase(
label="the last one switched off leaves nothing selected",
- checked=frozenset({GeneratorName.NOISE}),
- generator=GeneratorName.NOISE,
+ checked=frozenset({ChannelName.NOISE}),
+ channel=ChannelName.NOISE,
expected=frozenset(),
),
)
@@ -92,7 +91,7 @@ def test_the_set_the_checkboxes_show(
) -> None:
harness = Harness(test_case.checked, monkeypatch)
- harness.panel.toggle_generator(test_case.generator)
+ harness.panel.toggle_channel(test_case.channel)
assert harness.checked() == test_case.expected
@@ -109,12 +108,12 @@ def test_the_settings_the_panel_reports(
"""A switch reaches the configuration the same way a click does, drive carried along."""
harness = Harness(test_case.checked, monkeypatch)
- harness.panel.toggle_generator(test_case.generator)
+ harness.panel.toggle_channel(test_case.channel)
assert harness.reported == [
GenerationSettingsUpdate(
drive=DRIVE,
- generators=[generator for generator in GeneratorName if generator in test_case.expected],
+ channels=[channel for channel in ChannelName if channel in test_case.expected],
)
]
@@ -122,36 +121,36 @@ def test_switching_a_generator_twice_returns_the_set_it_started_from(
self,
monkeypatch: pytest.MonkeyPatch,
) -> None:
- harness = Harness(ALL_GENERATORS, monkeypatch)
+ harness = Harness(ALL_CHANNELS, monkeypatch)
- harness.panel.toggle_generator(GeneratorName.PULSE2)
- harness.panel.toggle_generator(GeneratorName.PULSE2)
+ harness.panel.toggle_channel(ChannelName.PULSE2)
+ harness.panel.toggle_channel(ChannelName.PULSE2)
- assert harness.checked() == ALL_GENERATORS
+ assert harness.checked() == ALL_CHANNELS
def test_the_generators_are_reported_in_the_order_the_tracker_shows_them(
self,
monkeypatch: pytest.MonkeyPatch,
) -> None:
- harness = Harness(frozenset({GeneratorName.NOISE, GeneratorName.PULSE1}), monkeypatch)
+ harness = Harness(frozenset({ChannelName.NOISE, ChannelName.PULSE1}), monkeypatch)
- harness.panel.toggle_generator(GeneratorName.TRIANGLE)
+ harness.panel.toggle_channel(ChannelName.TRIANGLE)
assert self._generators(harness.reported) == [
- GeneratorName.PULSE1,
- GeneratorName.TRIANGLE,
- GeneratorName.NOISE,
+ ChannelName.PULSE1,
+ ChannelName.TRIANGLE,
+ ChannelName.NOISE,
]
@staticmethod
- def _generators(reported: List[GenerationSettingsUpdate]) -> List[GeneratorName]:
- return list(reported[-1].generators)
+ def _generators(reported: List[GenerationSettingsUpdate]) -> List[ChannelName]:
+ return list(reported[-1].channels)
class TestCheckboxTags:
def test_each_generator_carries_a_tag_of_its_own(self) -> None:
tags: Tuple[str, ...] = tuple(
- GUIReconstructorPanel._get_generator_checkbox_tag(generator) for generator in GeneratorName
+ GUIReconstructorPanel._get_generator_checkbox_tag(channel) for channel in ChannelName
)
assert len(set(tags)) == len(tags)
diff --git a/tests/unit/sampletones_application/ui/panels/reconstruction/test_audio_panel.py b/tests/unit/sampletones_application/ui/panels/reconstruction/test_audio_panel.py
new file mode 100644
index 000000000..d1ef2d3f8
--- /dev/null
+++ b/tests/unit/sampletones_application/ui/panels/reconstruction/test_audio_panel.py
@@ -0,0 +1,128 @@
+from typing import Iterator, Optional
+
+import dearpygui.dearpygui as dpg
+import pytest
+
+from sampletones_application.categories.manager import LanguageManager
+from sampletones_application.layout.config import LayoutConfig
+from sampletones_application.layout.loader import load_layout_config
+from sampletones_application.paths import (
+ BEHAVIOR_DIRECTORY,
+ LANG_EN,
+ LAYOUT_DIRECTORY,
+ PALETTES_DIRECTORY,
+ THEME_DIRECTORY,
+)
+from sampletones_application.tags.reconstructions import (
+ TAG_RECONSTRUCTIONS_RECONSTRUCTION_TEXT_NES_FREQUENCY,
+)
+from sampletones_application.ui.elements.fonts.font import Font
+from sampletones_application.ui.elements.fonts.registry import FontRegistry
+from sampletones_application.ui.elements.panel import GUIPanel
+from sampletones_application.ui.elements.status import GUIStatusBar
+from sampletones_application.ui.panels.reconstruction.audio import (
+ GUIReconstructionAudioPanel,
+)
+from sampletones_application.ui.themes.registry import ThemeRegistry
+from sampletones_application.ui.themes.setup import setup_themes
+from sampletones_application.utils.palette.catalog import PaletteCatalog
+from sampletones_application.utils.palette.source import PaletteSource
+from sampletones_application.view_model.reconstruction.paths.path import (
+ ReconstructionPathViewModel,
+)
+from sampletones_application.view_model.reconstruction.paths.state import (
+ ReconstructionPathState,
+)
+from sampletones_application.view_model.reconstruction.reconstruction import (
+ ReconstructionViewModel,
+)
+from sampletones_core.configs.display import format_nes_frequency
+from sampletones_shared.constants.nes import PAL_FREQUENCY
+
+ROOT_TAG = "test_root"
+
+
+@pytest.fixture
+def layout_config() -> LayoutConfig:
+ source = PaletteSource(PaletteCatalog.load(PALETTES_DIRECTORY).default)
+ return load_layout_config(LAYOUT_DIRECTORY, BEHAVIOR_DIRECTORY, source)
+
+
+@pytest.fixture
+def dpg_context(layout_config: LayoutConfig) -> Iterator[None]:
+ """Stands up the context, fonts, themes, and section-header geometry the panel resolves on construction."""
+ dpg.create_context()
+ FontRegistry.setup(layout_config.fonts)
+ FontRegistry.register_fonts(layout_config.fonts.scale)
+ setup_themes(THEME_DIRECTORY, PaletteSource(PaletteCatalog.load(PALETTES_DIRECTORY).default))
+ GUIPanel.configure_section_header(
+ layout_config.glyphs,
+ layout_config.general.section_header,
+ layout_config.general.collapse,
+ )
+ try:
+ yield
+ finally:
+ ThemeRegistry.clear()
+ dpg.destroy_context()
+
+
+@pytest.fixture
+def panel(dpg_context: None, layout_config: LayoutConfig) -> GUIReconstructionAudioPanel:
+ return GUIReconstructionAudioPanel(
+ path_colors=layout_config.general.colors.paths,
+ path_status_color=layout_config.general.colors.text.disabled,
+ language_manager=LanguageManager(LANG_EN),
+ status_bar=GUIStatusBar(),
+ )
+
+
+@pytest.fixture
+def rendered_panel(panel: GUIReconstructionAudioPanel) -> GUIReconstructionAudioPanel:
+ with dpg.window(tag=ROOT_TAG):
+ panel.create_panel(ROOT_TAG)
+
+ return panel
+
+
+def _view_model(nes_frequency: Optional[int]) -> ReconstructionViewModel:
+ empty_path = ReconstructionPathViewModel(state=ReconstructionPathState.EMPTY, paths=())
+ return ReconstructionViewModel(
+ reconstruction_loaded=nes_frequency is not None,
+ playing_channels=frozenset(),
+ selected_channels=frozenset(),
+ reconstruction_file=empty_path,
+ original_audio=empty_path,
+ nes_frequency=nes_frequency,
+ )
+
+
+class TestEngineRateReadout:
+ """The rate the card states for the open reconstruction."""
+
+ def test_a_loaded_reconstruction_states_its_rate(
+ self,
+ rendered_panel: GUIReconstructionAudioPanel,
+ ) -> None:
+ rendered_panel.update_view(_view_model(PAL_FREQUENCY))
+
+ assert dpg.get_value(TAG_RECONSTRUCTIONS_RECONSTRUCTION_TEXT_NES_FREQUENCY) == format_nes_frequency(
+ PAL_FREQUENCY,
+ )
+
+ def test_an_empty_tab_leaves_the_readout_blank(
+ self,
+ rendered_panel: GUIReconstructionAudioPanel,
+ ) -> None:
+ rendered_panel.update_view(_view_model(PAL_FREQUENCY))
+ rendered_panel.update_view(_view_model(None))
+
+ assert dpg.get_value(TAG_RECONSTRUCTIONS_RECONSTRUCTION_TEXT_NES_FREQUENCY) == ""
+
+ def test_the_rate_reads_as_a_monospaced_figure(
+ self,
+ rendered_panel: GUIReconstructionAudioPanel,
+ ) -> None:
+ readout = dpg.get_item_info(TAG_RECONSTRUCTIONS_RECONSTRUCTION_TEXT_NES_FREQUENCY)
+
+ assert readout["font"] == FontRegistry.get_tag(Font.MONO)
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 9b9104241..96157ad7f 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
@@ -2,6 +2,8 @@
from typing import Dict, Final, List, cast
from unittest.mock import MagicMock
+import dearpygui.dearpygui as dpg
+import numpy as np
import pytest
from sampletones_application.categories.manager import LanguageManager
@@ -14,26 +16,41 @@
PALETTES_DIRECTORY,
THEME_DIRECTORY,
)
+from sampletones_application.tags.compose import compose_tag
from sampletones_application.tags.general import (
TAG_GLOBAL_THEME_DEFAULT,
TAG_GLOBAL_THEME_INPUT_WARNING,
TAG_GLOBAL_THEME_INSTRUMENT_TABS,
TAG_GLOBAL_THEME_INSTRUMENT_TABS_MUTED,
)
+from sampletones_application.tags.graphs import SUF_GRAPH_RAW_DATA
from sampletones_application.ui.elements.button import GUIButton
from sampletones_application.ui.elements.panel import GUIPanel
from sampletones_application.ui.elements.pitch_stepper import PitchStepperStyle
from sampletones_application.ui.panels.reconstruction.instruments import instruments as instruments_module
-from sampletones_application.ui.panels.reconstruction.instruments.instruments import GUIReconstructionInstrumentsPanel
+from sampletones_application.ui.panels.reconstruction.instruments.instruments import (
+ GUIReconstructionInstrumentsPanel,
+)
from sampletones_application.ui.themes.setup import setup_themes
from sampletones_application.ui.themes.theme import Theme
+from sampletones_application.utils.gui.keyboard import KeyEvent, KeyRouter
+from sampletones_application.utils.gui.keyboard.piano import PIANO_KEYS
from sampletones_application.utils.palette.catalog import PaletteCatalog
from sampletones_application.utils.palette.source import PaletteSource
-from sampletones_application.view_model.reconstruction.instruments import ReconstructionInstrumentsViewModel
-from sampletones_application.view_model.shared.footprint import SampleFootprintViewModel
-from sampletones_core.constants.enums import FeatureKey, GeneratorName
+from sampletones_application.view_model.reconstruction.instruments import (
+ InstrumentViewModel,
+ ReconstructionInstrumentsViewModel,
+)
+from sampletones_application.view_model.shared.footprint import VoiceFootprintViewModel
+from sampletones_core.constants.enums import ChannelName, FeatureKey, GeneratorName
+from sampletones_core.constants.general import PITCH_BEND_MAX, PITCH_BEND_MIN
+from sampletones_core.features import CHANNEL_GENERATOR_KIND, supported_features
+from sampletones_core.features.envelope import Envelope
from sampletones_core.formats.famitracker.footprint import InstrumentFootprint
-from sampletones_core.formats.famitracker.specification.sequences import MAX_SEQUENCE_ITEMS
+from sampletones_core.formats.famitracker.specification.sequences import (
+ MAX_SEQUENCE_ITEMS,
+)
+from sampletones_shared.constants.general import HEXADECIMAL_BASE
from tests.suite.base import BaseTestSuite
from tests.suite.case import BaseRegularTestCase
@@ -45,19 +62,31 @@
NOT_LOADED: Final[ReconstructionInstrumentsViewModel] = ReconstructionInstrumentsViewModel(
reconstruction_loaded=False,
- playing_generators=frozenset(),
+ playing_channels=frozenset(),
footprint=None,
)
+ONE_INSTRUMENT: Final[ReconstructionInstrumentsViewModel] = ReconstructionInstrumentsViewModel(
+ reconstruction_loaded=False,
+ playing_channels=frozenset((ChannelName.PULSE1,)),
+ footprint=VoiceFootprintViewModel.from_instrument(LARGEST_PULSE),
+ instrument=InstrumentViewModel(name="lead"),
+)
+
+
+def sequence(item_count: int) -> Envelope[int]:
+ """A dimension of a given length, which is all the length warning reads of it."""
+ return Envelope[int](items=(0,) * item_count)
+
def build_view_model(
- channel_footprints: Dict[GeneratorName, InstrumentFootprint],
+ channel_footprints: Dict[ChannelName, InstrumentFootprint],
) -> ReconstructionInstrumentsViewModel:
"""A loaded reconstruction playing the given channels, each measured as given."""
return ReconstructionInstrumentsViewModel(
reconstruction_loaded=True,
- playing_generators=frozenset(channel_footprints),
- footprint=SampleFootprintViewModel.from_footprints(channel_footprints),
+ playing_channels=frozenset(channel_footprints),
+ footprint=VoiceFootprintViewModel.from_footprints(channel_footprints),
)
@@ -103,15 +132,27 @@ def shown(monkeypatch: pytest.MonkeyPatch) -> Dict[str, bool]:
"""Records which items the panel shows, standing in for the DPG configuration."""
flags: Dict[str, bool] = {}
- def configure(tag: str, *, show: bool) -> None:
- flags[tag] = show
+ def configure(tag: str, **kwargs: object) -> None:
+ show = kwargs.get("show")
+ if isinstance(show, bool):
+ flags[tag] = show
monkeypatch.setattr(instruments_module, "dpg_configure_item", configure)
return flags
@pytest.fixture
-def panel(layout_config: LayoutConfig) -> GUIReconstructionInstrumentsPanel:
+def key_router() -> MagicMock:
+ router = MagicMock(spec=KeyRouter)
+ router.is_field_focused = False
+ return router
+
+
+@pytest.fixture
+def panel(
+ layout_config: LayoutConfig,
+ key_router: MagicMock,
+) -> GUIReconstructionInstrumentsPanel:
return GUIReconstructionInstrumentsPanel(
pitch_stepper_style=PitchStepperStyle.from_general(layout_config.general),
copy_width=layout_config.general.buttons.copy_width,
@@ -119,6 +160,8 @@ def panel(layout_config: LayoutConfig) -> GUIReconstructionInstrumentsPanel:
layout_graphs=layout_config.graphs,
language_manager=LanguageManager(LANG_EN),
status_bar=MagicMock(),
+ key_router=key_router,
+ tab_active=lambda: True,
)
@@ -134,7 +177,7 @@ def test_a_sequence_within_the_limit_keeps_the_default_theme(
bound_themes: List[str],
item_count: int,
) -> None:
- panel._apply_input_theme(GeneratorName.PULSE1, FeatureKey.VOLUME, item_count)
+ panel._show_sequence(ChannelName.PULSE1, FeatureKey.VOLUME, sequence(item_count))
assert bound_themes == [TAG_GLOBAL_THEME_DEFAULT]
def test_a_sequence_beyond_the_limit_takes_the_warning_theme(
@@ -142,7 +185,7 @@ def test_a_sequence_beyond_the_limit_takes_the_warning_theme(
panel: GUIReconstructionInstrumentsPanel,
bound_themes: List[str],
) -> None:
- panel._apply_input_theme(GeneratorName.PULSE1, FeatureKey.VOLUME, MAX_SEQUENCE_ITEMS + 1)
+ panel._show_sequence(ChannelName.PULSE1, FeatureKey.VOLUME, sequence(MAX_SEQUENCE_ITEMS + 1))
assert bound_themes == [TAG_GLOBAL_THEME_INPUT_WARNING]
def test_a_shortened_sequence_returns_to_the_default_theme(
@@ -150,8 +193,8 @@ def test_a_shortened_sequence_returns_to_the_default_theme(
panel: GUIReconstructionInstrumentsPanel,
bound_themes: List[str],
) -> None:
- panel._apply_input_theme(GeneratorName.NOISE, FeatureKey.VOLUME, MAX_SEQUENCE_ITEMS + 40)
- panel._apply_input_theme(GeneratorName.NOISE, FeatureKey.VOLUME, MAX_SEQUENCE_ITEMS)
+ panel._show_sequence(ChannelName.NOISE, FeatureKey.VOLUME, sequence(MAX_SEQUENCE_ITEMS + 40))
+ panel._show_sequence(ChannelName.NOISE, FeatureKey.VOLUME, sequence(MAX_SEQUENCE_ITEMS))
assert bound_themes == [
TAG_GLOBAL_THEME_INPUT_WARNING,
TAG_GLOBAL_THEME_DEFAULT,
@@ -162,40 +205,136 @@ def test_each_dimension_carries_its_own_length(
panel: GUIReconstructionInstrumentsPanel,
bound_themes: List[str],
) -> None:
- panel._apply_input_theme(GeneratorName.PULSE1, FeatureKey.VOLUME, MAX_SEQUENCE_ITEMS + 1)
- panel._apply_input_theme(GeneratorName.PULSE1, FeatureKey.ARPEGGIO, 8)
+ panel._show_sequence(ChannelName.PULSE1, FeatureKey.VOLUME, sequence(MAX_SEQUENCE_ITEMS + 1))
+ panel._show_sequence(ChannelName.PULSE1, FeatureKey.ARPEGGIO, sequence(8))
assert bound_themes == [
TAG_GLOBAL_THEME_INPUT_WARNING,
TAG_GLOBAL_THEME_DEFAULT,
]
+class TestEditingASequence:
+ """A bar redrawn on the plot restates the values; the item the dimension repeats from is its own."""
+
+ def test_a_redrawn_bar_states_the_values_it_leaves(
+ self,
+ panel: GUIReconstructionInstrumentsPanel,
+ ) -> None:
+ edited: List[Envelope[int]] = []
+ panel.on_envelope_changed = lambda _channel, _key, envelope: edited.append(envelope)
+ panel._show_sequence(ChannelName.PULSE1, FeatureKey.VOLUME, Envelope[int](items=(15, 12, 8)))
+
+ panel._on_bar_point_clicked(
+ ChannelName.PULSE1,
+ FeatureKey.VOLUME,
+ np.array([15, 4, 8], dtype=np.int8),
+ "plot",
+ )
+
+ assert edited == [Envelope[int](items=(15, 4, 8))]
+
+ def test_a_redrawn_bar_keeps_the_item_the_dimension_repeats_from(
+ self,
+ panel: GUIReconstructionInstrumentsPanel,
+ ) -> None:
+ edited: List[Envelope[int]] = []
+ panel.on_envelope_changed = lambda _channel, _key, envelope: edited.append(envelope)
+ panel._show_sequence(
+ ChannelName.PULSE1,
+ FeatureKey.VOLUME,
+ Envelope[int](items=(15, 12, 8), loop_point=1),
+ )
+
+ panel._on_bar_point_clicked(
+ ChannelName.PULSE1,
+ FeatureKey.VOLUME,
+ np.array([15, 4, 8], dtype=np.int8),
+ "plot",
+ )
+
+ assert edited == [Envelope[int](items=(15, 4, 8), loop_point=1)]
+
+ def test_a_redrawn_bar_writes_the_dimension_out_with_its_point(
+ self,
+ panel: GUIReconstructionInstrumentsPanel,
+ written: Dict[str, str],
+ ) -> None:
+ panel._show_sequence(
+ ChannelName.PULSE1,
+ FeatureKey.VOLUME,
+ Envelope[int](items=(15, 12, 8), loop_point=1),
+ )
+
+ panel._on_bar_point_clicked(
+ ChannelName.PULSE1,
+ FeatureKey.VOLUME,
+ np.array([15, 4, 8], dtype=np.int8),
+ "plot",
+ )
+
+ assert written[compose_tag("plot", SUF_GRAPH_RAW_DATA)] == "15 | 4 8"
+
+
+class TestCopyingASequence:
+ def test_the_copy_button_hands_over_the_dimension_the_input_shows(
+ self,
+ panel: GUIReconstructionInstrumentsPanel,
+ monkeypatch: pytest.MonkeyPatch,
+ ) -> None:
+ """The button is built while every dimension is still empty, so the press reads the input."""
+ copied: List[str] = []
+ monkeypatch.setattr(
+ GUIReconstructionInstrumentsPanel,
+ "_on_copy_button_clicked",
+ lambda _self, text, _tag: copied.append(text),
+ )
+ callback = panel._copy_callback(ChannelName.PULSE1, FeatureKey.VOLUME, "button")
+ panel._show_sequence(
+ ChannelName.PULSE1,
+ FeatureKey.VOLUME,
+ Envelope[int](items=(15, 12, 8), loop_point=1),
+ )
+
+ callback()
+
+ assert copied == ["15 | 12 8"]
+
+ 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."""
+ callback = panel._copy_callback(ChannelName.PULSE1, FeatureKey.VOLUME, "button")
+
+ assert callback.__code__.co_argcount == 0
+
+
class TestInstrumentExport:
- """The export button carries the generator whose slice it writes; the destination the
+ """The export button carries the channel 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] = []
+ calls: List[ChannelName] = []
panel.on_instrument_export = calls.append
- panel._export_callback(GeneratorName.NOISE)()
+ panel._export_callback(ChannelName.NOISE)()
- assert calls == [GeneratorName.NOISE]
+ assert calls == [ChannelName.NOISE]
def test_each_generator_gets_its_own_handler(
self,
panel: GUIReconstructionInstrumentsPanel,
) -> None:
- calls: List[GeneratorName] = []
+ calls: List[ChannelName] = []
panel.on_instrument_export = calls.append
- for generator_name in GeneratorName.items():
- panel._export_callback(generator_name)()
+ for channel_name in ChannelName.items():
+ panel._export_callback(channel_name)()
- assert calls == list(GeneratorName.items())
+ assert calls == list(ChannelName.items())
def test_the_handler_is_one_the_framework_can_dispatch(
self,
@@ -204,7 +343,7 @@ def test_the_handler_is_one_the_framework_can_dispatch(
"""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)
+ callback = panel._export_callback(ChannelName.NOISE)
assert callback.__code__.co_argcount == 0
@@ -215,8 +354,15 @@ def test_a_sequence_within_the_limit_describes_editing(
panel: GUIReconstructionInstrumentsPanel,
bound_themes: List[str],
) -> None:
- panel._apply_input_theme(GeneratorName.PULSE1, FeatureKey.VOLUME, 16)
- message = panel._sequence_status_message(GeneratorName.PULSE1, FeatureKey.VOLUME)
+ panel._show_sequence(
+ ChannelName.PULSE1,
+ FeatureKey.VOLUME,
+ sequence(HEXADECIMAL_BASE),
+ )
+ message = panel._sequence_status_message(
+ ChannelName.PULSE1,
+ FeatureKey.VOLUME,
+ )
assert message == panel._language_manager[SEQUENCE_STATUS_KEY].format(
instrument_feature=FeatureKey.VOLUME.capitalized
)
@@ -226,8 +372,8 @@ def test_a_sequence_beyond_the_limit_names_the_limit(
panel: GUIReconstructionInstrumentsPanel,
bound_themes: List[str],
) -> None:
- panel._apply_input_theme(GeneratorName.PULSE1, FeatureKey.VOLUME, 300)
- message = panel._sequence_status_message(GeneratorName.PULSE1, FeatureKey.VOLUME)
+ panel._show_sequence(ChannelName.PULSE1, FeatureKey.VOLUME, sequence(300))
+ message = panel._sequence_status_message(ChannelName.PULSE1, FeatureKey.VOLUME)
assert "300" in message
assert str(MAX_SEQUENCE_ITEMS) in message
@@ -237,27 +383,27 @@ class TestSizeFields(BaseTestSuite):
@dataclass(frozen=True, kw_only=True)
class TestCase(BaseRegularTestCase):
- channel_footprints: Dict[GeneratorName, InstrumentFootprint]
+ channel_footprints: Dict[ChannelName, InstrumentFootprint]
expected: str
test_cases = (
TestCase(
label="a single channel spends what its instrument does",
- channel_footprints={GeneratorName.PULSE1: LARGEST_PULSE},
+ channel_footprints={ChannelName.PULSE1: LARGEST_PULSE},
expected="777 B",
),
TestCase(
label="three channels spend their instruments together",
channel_footprints={
- GeneratorName.PULSE1: LARGEST_PULSE,
- GeneratorName.TRIANGLE: LARGEST_TRIANGLE,
- GeneratorName.NOISE: LARGEST_PULSE,
+ ChannelName.PULSE1: LARGEST_PULSE,
+ ChannelName.TRIANGLE: LARGEST_TRIANGLE,
+ ChannelName.NOISE: LARGEST_PULSE,
},
expected="2073 B",
),
TestCase(
label="a silent channel spends the instrument definition alone",
- channel_footprints={GeneratorName.TRIANGLE: SILENT_INSTRUMENT},
+ channel_footprints={ChannelName.TRIANGLE: SILENT_INSTRUMENT},
expected="3 B",
),
)
@@ -281,11 +427,11 @@ def test_each_channel_states_its_own_size(
) -> None:
panel.update_view(build_view_model(test_case.channel_footprints))
assert {
- generator_name: written[panel._get_instrument_size_tag(generator_name)]
- for generator_name in test_case.channel_footprints
+ channel_name: written[panel._get_instrument_size_tag(channel_name)]
+ for channel_name in test_case.channel_footprints
} == {
- generator_name: f"{footprint.total_bytes} B"
- for generator_name, footprint in test_case.channel_footprints.items()
+ channel_name: f"{footprint.total_bytes} B"
+ for channel_name, footprint in test_case.channel_footprints.items()
}
@pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label)
@@ -298,13 +444,13 @@ def test_a_channel_standing_by_costs_nothing(
"""A channel that describes no frame is written by no export, so its tab states what that costs."""
panel.update_view(build_view_model(test_case.channel_footprints))
assert {
- generator_name: written[panel._get_instrument_size_tag(generator_name)]
- for generator_name in GeneratorName.items()
- if generator_name not in test_case.channel_footprints
+ channel_name: written[panel._get_instrument_size_tag(channel_name)]
+ for channel_name in ChannelName.items()
+ if channel_name not in test_case.channel_footprints
} == {
- generator_name: "0 B"
- for generator_name in GeneratorName.items()
- if generator_name not in test_case.channel_footprints
+ channel_name: "0 B"
+ for channel_name in ChannelName.items()
+ if channel_name not in test_case.channel_footprints
}
@@ -320,46 +466,109 @@ def test_every_channel_keeps_its_tab(
panel: GUIReconstructionInstrumentsPanel,
shown: Dict[str, bool],
) -> None:
- panel.update_view(build_view_model({GeneratorName.PULSE1: LARGEST_PULSE}))
+ panel.update_view(build_view_model({ChannelName.PULSE1: LARGEST_PULSE}))
assert {
- generator_name: shown[panel._get_generator_tab_tag(generator_name)]
- for generator_name in GeneratorName.items()
- } == {generator_name: True for generator_name in GeneratorName.items()}
+ channel_name: shown[panel._get_generator_tab_tag(channel_name)] for channel_name in ChannelName.items()
+ } == {channel_name: True for channel_name in ChannelName.items()}
def test_a_channel_standing_by_reads_muted(
self,
panel: GUIReconstructionInstrumentsPanel,
bound_themes: List[str],
) -> None:
- panel.update_view(build_view_model({GeneratorName.PULSE1: LARGEST_PULSE}))
- assert dict(zip(GeneratorName.items(), bound_themes)) == {
- GeneratorName.PULSE1: TAG_GLOBAL_THEME_INSTRUMENT_TABS,
- GeneratorName.PULSE2: TAG_GLOBAL_THEME_INSTRUMENT_TABS_MUTED,
- GeneratorName.TRIANGLE: TAG_GLOBAL_THEME_INSTRUMENT_TABS_MUTED,
- GeneratorName.NOISE: TAG_GLOBAL_THEME_INSTRUMENT_TABS_MUTED,
+ panel.update_view(build_view_model({ChannelName.PULSE1: LARGEST_PULSE}))
+ assert dict(zip(ChannelName.items(), bound_themes)) == {
+ ChannelName.PULSE1: TAG_GLOBAL_THEME_INSTRUMENT_TABS,
+ ChannelName.PULSE2: TAG_GLOBAL_THEME_INSTRUMENT_TABS_MUTED,
+ ChannelName.TRIANGLE: TAG_GLOBAL_THEME_INSTRUMENT_TABS_MUTED,
+ ChannelName.NOISE: TAG_GLOBAL_THEME_INSTRUMENT_TABS_MUTED,
}
def test_only_a_playing_channel_offers_its_export(
self,
panel: GUIReconstructionInstrumentsPanel,
) -> None:
- buttons = {generator_name: MagicMock() for generator_name in GeneratorName.items()}
- panel._export_buttons.update(cast(Dict[GeneratorName, GUIButton], buttons))
+ buttons = {channel_name: MagicMock() for channel_name in ChannelName.items()}
+ panel._export_buttons.update(cast(Dict[ChannelName, GUIButton], buttons))
- panel.update_view(build_view_model({GeneratorName.TRIANGLE: LARGEST_TRIANGLE}))
+ panel.update_view(build_view_model({ChannelName.TRIANGLE: LARGEST_TRIANGLE}))
- assert {generator_name: button.set_enabled.call_args.args[0] for generator_name, button in buttons.items()} == {
- generator_name: generator_name is GeneratorName.TRIANGLE for generator_name in GeneratorName.items()
+ assert {channel_name: button.set_enabled.call_args.args[0] for channel_name, button in buttons.items()} == {
+ channel_name: channel_name is ChannelName.TRIANGLE for channel_name in ChannelName.items()
}
+class TestTheDimensionsAChannelShows:
+ """A channel plots the dimensions its generator offers, the bend among them where it reads one.
+
+ The panel builds one row per offered dimension, so what a channel shows follows the generator
+ spec rather than a list of its own.
+ """
+
+ @pytest.mark.parametrize(
+ "channel_name",
+ ChannelName.items(),
+ ids=lambda channel_name: str(channel_name),
+ )
+ def test_a_channel_plots_every_dimension_it_offers(
+ self,
+ panel: GUIReconstructionInstrumentsPanel,
+ channel_name: ChannelName,
+ ) -> None:
+ kind = CHANNEL_GENERATOR_KIND[channel_name]
+
+ assert list(panel._feature_plot_configs[kind]) == supported_features(kind)
+
+ @pytest.mark.parametrize(
+ "kind",
+ (GeneratorName.PULSE, GeneratorName.TRIANGLE),
+ ids=lambda kind: str(kind),
+ )
+ def test_a_tonal_channel_plots_both_bend_dimensions(
+ self,
+ panel: GUIReconstructionInstrumentsPanel,
+ kind: GeneratorName,
+ ) -> None:
+ plotted = panel._feature_plot_configs[kind]
+
+ assert FeatureKey.PITCH in plotted
+ assert FeatureKey.HI_PITCH in plotted
+
+ def test_the_noise_channel_plots_no_bend(self, panel: GUIReconstructionInstrumentsPanel) -> None:
+ plotted = panel._feature_plot_configs[GeneratorName.NOISE]
+
+ assert FeatureKey.PITCH not in plotted
+ assert FeatureKey.HI_PITCH not in plotted
+
+ def test_the_two_bend_dimensions_read_apart(self, panel: GUIReconstructionInstrumentsPanel) -> None:
+ """One counts a divider step and the other sixteen, so a reader tells them apart at a glance."""
+ plotted = panel._feature_plot_configs[GeneratorName.PULSE]
+
+ assert plotted[FeatureKey.PITCH].color != plotted[FeatureKey.HI_PITCH].color
+ assert plotted[FeatureKey.PITCH].label != plotted[FeatureKey.HI_PITCH].label
+
+ @pytest.mark.parametrize(
+ "feature_key",
+ (FeatureKey.PITCH, FeatureKey.HI_PITCH),
+ ids=lambda feature_key: str(feature_key),
+ )
+ def test_a_bend_plot_spans_the_range_a_sequence_stores(
+ self,
+ panel: GUIReconstructionInstrumentsPanel,
+ feature_key: FeatureKey,
+ ) -> None:
+ plotted = panel._feature_plot_configs[GeneratorName.PULSE][feature_key]
+
+ assert plotted.data_range == (PITCH_BEND_MIN, PITCH_BEND_MAX)
+
+
class TestSizeVisibility:
def test_a_loaded_reconstruction_shows_the_sample_size(
self,
panel: GUIReconstructionInstrumentsPanel,
shown: Dict[str, bool],
) -> None:
- panel.update_view(build_view_model({GeneratorName.PULSE1: LARGEST_PULSE}))
+ panel.update_view(build_view_model({ChannelName.PULSE1: LARGEST_PULSE}))
assert shown[panel.sample_size_group_tag] is True
def test_no_reconstruction_hides_the_sample_size(
@@ -377,3 +586,102 @@ def test_no_reconstruction_states_no_figures(
) -> None:
panel.update_view(NOT_LOADED)
assert written == {}
+
+
+class TestTheAuditionSelector:
+ """The generator a hand-written voice is heard on stands where the pitch stepper does."""
+
+ def test_an_open_instrument_offers_the_generator_to_hear_it_on(
+ self,
+ panel: GUIReconstructionInstrumentsPanel,
+ shown: Dict[str, bool],
+ ) -> None:
+ panel.update_view(ONE_INSTRUMENT)
+ assert shown[panel.audition_group_tag] is True
+
+ def test_a_loaded_reconstruction_offers_the_pitch_it_was_measured_against_instead(
+ self,
+ panel: GUIReconstructionInstrumentsPanel,
+ shown: Dict[str, bool],
+ ) -> None:
+ panel.update_view(build_view_model({ChannelName.PULSE1: LARGEST_PULSE}))
+ assert shown[panel.audition_group_tag] is False
+
+ def test_choosing_a_generator_reports_the_one_its_name_stands_for(
+ self,
+ panel: GUIReconstructionInstrumentsPanel,
+ ) -> None:
+ chosen: List[GeneratorName] = []
+ panel.on_audition_generator_changed = chosen.append
+
+ for generator_name in GeneratorName:
+ panel._on_audition_generator_changed("sender", panel._generator_labels[generator_name])
+
+ assert chosen == list(GeneratorName)
+
+
+class TestTheNoteKeys:
+ """A note key sounds the instrument in front of the panel, and claims the press it used."""
+
+ def test_a_note_key_asks_for_the_note_it_names(
+ self,
+ panel: GUIReconstructionInstrumentsPanel,
+ ) -> None:
+ sounded: List[int] = []
+ panel.on_audition_requested = sounded.append
+ panel.update_view(ONE_INSTRUMENT)
+
+ assert panel._on_key_pressed(KeyEvent(key=dpg.mvKey_Z, modifiers=frozenset())) is True
+ assert sounded == [PIANO_KEYS[dpg.mvKey_Z]]
+
+ def test_a_key_naming_no_note_is_left_to_the_shortcuts(
+ self,
+ panel: GUIReconstructionInstrumentsPanel,
+ ) -> None:
+ sounded: List[int] = []
+ panel.on_audition_requested = sounded.append
+ panel.update_view(ONE_INSTRUMENT)
+
+ assert panel._on_key_pressed(KeyEvent(key=dpg.mvKey_Spacebar, modifiers=frozenset())) is False
+ assert sounded == []
+
+ def test_the_keys_answer_while_an_instrument_is_open(
+ self,
+ panel: GUIReconstructionInstrumentsPanel,
+ ) -> None:
+ panel.update_view(ONE_INSTRUMENT)
+ assert panel._audition_keys_active() is True
+
+ def test_a_loaded_reconstruction_keeps_the_keys_out_of_the_panel(
+ self,
+ panel: GUIReconstructionInstrumentsPanel,
+ ) -> None:
+ panel.update_view(build_view_model({ChannelName.PULSE1: LARGEST_PULSE}))
+ assert panel._audition_keys_active() is False
+
+ def test_a_field_being_typed_into_keeps_its_own_characters(
+ self,
+ panel: GUIReconstructionInstrumentsPanel,
+ key_router: MagicMock,
+ ) -> None:
+ panel.update_view(ONE_INSTRUMENT)
+ key_router.is_field_focused = True
+ assert panel._audition_keys_active() is False
+
+ def test_another_tab_in_front_keeps_the_keys_from_the_panel(
+ self,
+ layout_config: LayoutConfig,
+ key_router: MagicMock,
+ ) -> None:
+ panel = GUIReconstructionInstrumentsPanel(
+ pitch_stepper_style=PitchStepperStyle.from_general(layout_config.general),
+ copy_width=layout_config.general.buttons.copy_width,
+ feature_colors=layout_config.general.colors.features,
+ layout_graphs=layout_config.graphs,
+ language_manager=LanguageManager(LANG_EN),
+ status_bar=MagicMock(),
+ key_router=key_router,
+ tab_active=lambda: False,
+ )
+ panel.update_view(ONE_INSTRUMENT)
+ assert panel._audition_keys_active() is False
diff --git a/tests/unit/sampletones_application/ui/panels/reconstruction/test_plot.py b/tests/unit/sampletones_application/ui/panels/reconstruction/test_plot.py
index 76f4d1900..c6700a81d 100644
--- a/tests/unit/sampletones_application/ui/panels/reconstruction/test_plot.py
+++ b/tests/unit/sampletones_application/ui/panels/reconstruction/test_plot.py
@@ -1,22 +1,35 @@
from dataclasses import dataclass
from typing import Dict, FrozenSet, List
+import numpy as np
import pytest
+from sampletones_application.layout.general.colors.channel import ChannelColors
+from sampletones_application.tags.reconstructions import (
+ TAG_RECONSTRUCTIONS_RECONSTRUCTION_GROUP_CHANNELS,
+)
from sampletones_application.ui.panels.reconstruction import plot as plot_module
from sampletones_application.ui.panels.reconstruction.plot import (
GUIReconstructionPlotPanel,
)
-from sampletones_application.view_model.reconstruction.reconstruction import (
- ReconstructionPathState,
+from sampletones_application.utils.palette.colors.written import LiteralColor
+from sampletones_application.view_model.reconstruction.paths.path import (
ReconstructionPathViewModel,
+)
+from sampletones_application.view_model.reconstruction.paths.state import (
+ ReconstructionPathState,
+)
+from sampletones_application.view_model.reconstruction.reconstruction import (
ReconstructionViewModel,
)
-from sampletones_core.constants.enums import GeneratorName
+from sampletones_application.view_model.reconstruction.waveform import (
+ InstrumentWaveformViewModel,
+)
+from sampletones_core.constants.enums import ChannelName
from tests.suite.base import BaseTestSuite
from tests.suite.case import BaseRegularTestCase
-ALL_GENERATORS = frozenset(GeneratorName)
+ALL_CHANNELS = frozenset(ChannelName)
class StubTheme:
@@ -31,19 +44,19 @@ def bind_to_item(self, item: str) -> None:
class Harness:
- """The panel over its generator checkboxes, each shown or disabled as a reconstruction leaves
+ """The panel over its channel checkboxes, each shown or disabled as a reconstruction leaves
it."""
def __init__(
self,
*,
- selected: FrozenSet[GeneratorName],
- available: FrozenSet[GeneratorName],
+ selected: FrozenSet[ChannelName],
+ available: FrozenSet[ChannelName],
monkeypatch: pytest.MonkeyPatch,
) -> None:
- self.values: Dict[str, bool] = {self._tag(generator): generator in selected for generator in GeneratorName}
- self.enabled: Dict[str, bool] = {self._tag(generator): generator in available for generator in GeneratorName}
- self.reported: List[List[GeneratorName]] = []
+ self.values: Dict[str, bool] = {self._tag(channel): channel in selected for channel in ChannelName}
+ self.enabled: Dict[str, bool] = {self._tag(channel): channel in available for channel in ChannelName}
+ self.reported: List[List[ChannelName]] = []
self.bound_themes: Dict[str, str] = {}
monkeypatch.setattr(plot_module.dpg, "get_value", self.values.__getitem__)
@@ -58,38 +71,39 @@ def __init__(
)
self.panel = GUIReconstructionPlotPanel.__new__(GUIReconstructionPlotPanel)
- self.panel.on_generators_changed = self.reported.append
+ self.panel.on_channels_changed = self.reported.append
def _configure(self, tag: str, *, enabled: bool, default_value: bool) -> None:
self.enabled[tag] = enabled
self.values[tag] = default_value
@staticmethod
- def _tag(generator: GeneratorName) -> str:
- return GUIReconstructionPlotPanel._get_generator_checkbox_tag(generator)
+ def _tag(channel: ChannelName) -> str:
+ return GUIReconstructionPlotPanel._get_generator_checkbox_tag(channel)
- def offered(self) -> FrozenSet[GeneratorName]:
- return frozenset(generator for generator in GeneratorName if self.enabled[self._tag(generator)])
+ def offered(self) -> FrozenSet[ChannelName]:
+ return frozenset(channel for channel in ChannelName if self.enabled[self._tag(channel)])
- def selected(self) -> FrozenSet[GeneratorName]:
- return frozenset(generator for generator in GeneratorName if self.values[self._tag(generator)])
+ def selected(self) -> FrozenSet[ChannelName]:
+ return frozenset(channel for channel in ChannelName if self.values[self._tag(channel)])
def _view_model(
- playing: FrozenSet[GeneratorName],
- selected: FrozenSet[GeneratorName],
+ playing: FrozenSet[ChannelName],
+ selected: FrozenSet[ChannelName],
) -> ReconstructionViewModel:
- empty_path = ReconstructionPathViewModel(state=ReconstructionPathState.EMPTY, path="")
+ empty_path = ReconstructionPathViewModel(state=ReconstructionPathState.EMPTY, paths=())
return ReconstructionViewModel(
reconstruction_loaded=True,
- playing_generators=playing,
- selected_generators=selected,
+ playing_channels=playing,
+ selected_channels=selected,
reconstruction_file=empty_path,
original_audio=empty_path,
+ nes_frequency=None,
)
-class TestGeneratorCheckboxes:
+class TestChannelCheckboxes:
"""The checkboxes offer the channels that play and tick the ones the reader keeps on."""
def test_a_channel_that_plays_is_offered(
@@ -97,7 +111,7 @@ def test_a_channel_that_plays_is_offered(
monkeypatch: pytest.MonkeyPatch,
) -> None:
harness = Harness(selected=frozenset(), available=frozenset(), monkeypatch=monkeypatch)
- playing = frozenset({GeneratorName.PULSE1, GeneratorName.NOISE})
+ playing = frozenset({ChannelName.PULSE1, ChannelName.NOISE})
harness.panel.update_view(_view_model(playing, playing))
@@ -109,69 +123,69 @@ def test_a_channel_switched_off_by_hand_stays_off(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""An edit reports the view again, and the report carries the reader's choice."""
- harness = Harness(selected=ALL_GENERATORS, available=ALL_GENERATORS, monkeypatch=monkeypatch)
- playing = frozenset({GeneratorName.PULSE1, GeneratorName.NOISE})
+ harness = Harness(selected=ALL_CHANNELS, available=ALL_CHANNELS, monkeypatch=monkeypatch)
+ playing = frozenset({ChannelName.PULSE1, ChannelName.NOISE})
- harness.panel.update_view(_view_model(playing, frozenset({GeneratorName.NOISE})))
+ harness.panel.update_view(_view_model(playing, frozenset({ChannelName.NOISE})))
assert harness.offered() == playing
- assert harness.selected() == frozenset({GeneratorName.NOISE})
+ assert harness.selected() == frozenset({ChannelName.NOISE})
def test_a_channel_standing_by_is_left_unticked(
self,
monkeypatch: pytest.MonkeyPatch,
) -> None:
- harness = Harness(selected=ALL_GENERATORS, available=ALL_GENERATORS, monkeypatch=monkeypatch)
- playing = frozenset({GeneratorName.PULSE1})
+ harness = Harness(selected=ALL_CHANNELS, available=ALL_CHANNELS, monkeypatch=monkeypatch)
+ playing = frozenset({ChannelName.PULSE1})
harness.panel.update_view(_view_model(playing, playing))
assert harness.selected() == playing
- assert GeneratorName.PULSE2 not in harness.offered()
+ assert ChannelName.PULSE2 not in harness.offered()
def test_a_channel_that_plays_carries_its_own_tint(
self,
monkeypatch: pytest.MonkeyPatch,
) -> None:
harness = Harness(selected=frozenset(), available=frozenset(), monkeypatch=monkeypatch)
- playing = frozenset({GeneratorName.TRIANGLE})
+ playing = frozenset({ChannelName.TRIANGLE})
harness.panel.update_view(_view_model(playing, playing))
- assert set(harness.bound_themes) == {Harness._tag(GeneratorName.TRIANGLE)}
+ assert set(harness.bound_themes) == {Harness._tag(ChannelName.TRIANGLE)}
-class TestToggleGenerator(BaseTestSuite):
+class TestToggleChannel(BaseTestSuite):
"""The key a channel answers to switches its slice in and out of the waveform."""
@dataclass(frozen=True, kw_only=True)
class TestCase(BaseRegularTestCase):
- selected: FrozenSet[GeneratorName]
- available: FrozenSet[GeneratorName]
- generator: GeneratorName
- expected: FrozenSet[GeneratorName]
+ selected: FrozenSet[ChannelName]
+ available: FrozenSet[ChannelName]
+ channel: ChannelName
+ expected: FrozenSet[ChannelName]
test_cases = (
TestCase(
label="switching a shown slice out",
- selected=ALL_GENERATORS,
- available=ALL_GENERATORS,
- generator=GeneratorName.PULSE1,
- expected=ALL_GENERATORS - {GeneratorName.PULSE1},
+ selected=ALL_CHANNELS,
+ available=ALL_CHANNELS,
+ channel=ChannelName.PULSE1,
+ expected=ALL_CHANNELS - {ChannelName.PULSE1},
),
TestCase(
label="switching a hidden slice back in",
- selected=frozenset({GeneratorName.NOISE}),
- available=ALL_GENERATORS,
- generator=GeneratorName.TRIANGLE,
- expected=frozenset({GeneratorName.TRIANGLE, GeneratorName.NOISE}),
+ selected=frozenset({ChannelName.NOISE}),
+ available=ALL_CHANNELS,
+ channel=ChannelName.TRIANGLE,
+ expected=frozenset({ChannelName.TRIANGLE, ChannelName.NOISE}),
),
TestCase(
- label="a generator the reconstruction holds none of stays out",
- selected=frozenset({GeneratorName.PULSE1}),
- available=frozenset({GeneratorName.PULSE1}),
- generator=GeneratorName.NOISE,
- expected=frozenset({GeneratorName.PULSE1}),
+ label="a channel the reconstruction holds none of stays out",
+ selected=frozenset({ChannelName.PULSE1}),
+ available=frozenset({ChannelName.PULSE1}),
+ channel=ChannelName.NOISE,
+ expected=frozenset({ChannelName.PULSE1}),
),
)
@@ -191,13 +205,13 @@ def test_the_slices_the_checkboxes_show(
monkeypatch=monkeypatch,
)
- harness.panel.toggle_generator(test_case.generator)
+ harness.panel.toggle_channel(test_case.channel)
assert harness.selected() == test_case.expected
@pytest.mark.parametrize(
"test_case",
- [test_case for test_case in test_cases if test_case.generator in test_case.available],
+ [test_case for test_case in test_cases if test_case.channel in test_case.available],
ids=lambda test_case: test_case.label,
)
def test_the_selection_the_panel_reports(
@@ -212,10 +226,10 @@ def test_the_selection_the_panel_reports(
monkeypatch=monkeypatch,
)
- harness.panel.toggle_generator(test_case.generator)
+ harness.panel.toggle_channel(test_case.channel)
assert harness.reported == [
- [generator for generator in GeneratorName if generator in test_case.expected],
+ [channel for channel in ChannelName if channel in test_case.expected],
]
def test_a_generator_the_reconstruction_holds_none_of_reports_nothing(
@@ -224,12 +238,12 @@ def test_a_generator_the_reconstruction_holds_none_of_reports_nothing(
) -> None:
"""Its checkbox already reads as unavailable, so the key leaves the waveform as it stands."""
harness = Harness(
- selected=frozenset({GeneratorName.PULSE1}),
- available=frozenset({GeneratorName.PULSE1}),
+ selected=frozenset({ChannelName.PULSE1}),
+ available=frozenset({ChannelName.PULSE1}),
monkeypatch=monkeypatch,
)
- harness.panel.toggle_generator(GeneratorName.NOISE)
+ harness.panel.toggle_channel(ChannelName.NOISE)
assert harness.reported == []
@@ -238,12 +252,122 @@ def test_switching_a_slice_twice_returns_the_waveform_it_started_from(
monkeypatch: pytest.MonkeyPatch,
) -> None:
harness = Harness(
- selected=ALL_GENERATORS,
- available=ALL_GENERATORS,
+ selected=ALL_CHANNELS,
+ available=ALL_CHANNELS,
monkeypatch=monkeypatch,
)
- harness.panel.toggle_generator(GeneratorName.PULSE2)
- harness.panel.toggle_generator(GeneratorName.PULSE2)
+ harness.panel.toggle_channel(ChannelName.PULSE2)
+ harness.panel.toggle_channel(ChannelName.PULSE2)
+
+ assert harness.selected() == ALL_CHANNELS
+
+
+CHANNEL_COLORS = ChannelColors(
+ pulse1=LiteralColor((240, 146, 86, 255)),
+ pulse2=LiteralColor((242, 209, 95, 255)),
+ triangle=LiteralColor((140, 193, 237, 255)),
+ noise=LiteralColor((187, 184, 194, 255)),
+)
+
+AUTOSCALE_TAG = "autoscale"
+FRAME_LENGTH = 735
+
+
+class _WaveformRecorder:
+ """Stands in for the waveform graph, recording the one voice line it is asked to draw."""
+
+ def __init__(self) -> None:
+ self.drawn: List[Dict[str, object]] = []
+
+ def load_voice_waveform(self, audio: np.ndarray, *, name: str, color: object) -> None:
+ self.drawn.append({"audio": audio, "name": name, "color": color})
+
+
+class InstrumentHarness:
+ """The panel over the card an instrument is drawn on, with the recording controls it hides."""
+
+ def __init__(self, monkeypatch: pytest.MonkeyPatch) -> None:
+ self.shown: Dict[str, bool] = {}
+ monkeypatch.setattr(plot_module, "dpg_configure_item", self._configure)
+
+ self.waveform = _WaveformRecorder()
+ self.panel = GUIReconstructionPlotPanel.__new__(GUIReconstructionPlotPanel)
+ self.panel._channel_colors = CHANNEL_COLORS
+ self.panel.autoscale_tag = AUTOSCALE_TAG
+ self.panel._frame_length = None
+ self.panel.waveform_display = self.waveform
+
+ def _configure(self, tag: str, **kwargs: object) -> None:
+ show = kwargs.get("show")
+ if isinstance(show, bool):
+ self.shown[tag] = show
+
+
+def _waveform(channel_name: ChannelName) -> InstrumentWaveformViewModel:
+ return InstrumentWaveformViewModel(
+ name="lead",
+ channel_name=channel_name,
+ audio=np.zeros(2 * FRAME_LENGTH),
+ frame_length=FRAME_LENGTH,
+ )
+
+
+class TestTheCardAnInstrumentIsDrawnOn:
+ def test_an_instrument_is_drawn_under_its_own_name(
+ self,
+ monkeypatch: pytest.MonkeyPatch,
+ ) -> None:
+ harness = InstrumentHarness(monkeypatch)
+
+ harness.panel.update_instrument_view(_waveform(ChannelName.PULSE1))
+
+ assert harness.waveform.drawn[-1]["name"] == "lead"
+
+ def test_an_instrument_is_drawn_in_the_color_of_the_channel_it_sounds_on(
+ self,
+ monkeypatch: pytest.MonkeyPatch,
+ ) -> None:
+ harness = InstrumentHarness(monkeypatch)
+
+ harness.panel.update_instrument_view(_waveform(ChannelName.NOISE))
+
+ assert harness.waveform.drawn[-1]["color"] == CHANNEL_COLORS.noise
+
+ def test_the_frame_length_reaches_the_overlay_the_bars_move(
+ self,
+ monkeypatch: pytest.MonkeyPatch,
+ ) -> None:
+ harness = InstrumentHarness(monkeypatch)
+
+ harness.panel.update_instrument_view(_waveform(ChannelName.PULSE1))
+
+ assert harness.panel._frame_length == FRAME_LENGTH
+
+ def test_the_controls_that_read_a_recording_stand_down(
+ self,
+ monkeypatch: pytest.MonkeyPatch,
+ ) -> None:
+ harness = InstrumentHarness(monkeypatch)
+
+ harness.panel.update_instrument_view(_waveform(ChannelName.PULSE1))
+
+ assert harness.shown == {
+ AUTOSCALE_TAG: False,
+ TAG_RECONSTRUCTIONS_RECONSTRUCTION_GROUP_CHANNELS: False,
+ }
+
+ def test_a_recording_takes_the_card_and_its_controls_back(
+ self,
+ monkeypatch: pytest.MonkeyPatch,
+ ) -> None:
+ harness = InstrumentHarness(monkeypatch)
+ harness.panel.update_instrument_view(_waveform(ChannelName.PULSE1))
+
+ harness.panel.update_instrument_view(None)
- assert harness.selected() == ALL_GENERATORS
+ assert harness.shown == {
+ AUTOSCALE_TAG: True,
+ TAG_RECONSTRUCTIONS_RECONSTRUCTION_GROUP_CHANNELS: True,
+ }
+ assert len(harness.waveform.drawn) == 1
diff --git a/tests/unit/sampletones_application/ui/panels/reconstruction/test_stems_panel.py b/tests/unit/sampletones_application/ui/panels/reconstruction/test_stems_panel.py
new file mode 100644
index 000000000..0a8977fc0
--- /dev/null
+++ b/tests/unit/sampletones_application/ui/panels/reconstruction/test_stems_panel.py
@@ -0,0 +1,304 @@
+from pathlib import Path
+from typing import FrozenSet, Iterator, List, Tuple
+
+import dearpygui.dearpygui as dpg
+import pytest
+
+from sampletones_application.categories.manager import LanguageManager
+from sampletones_application.layout.config import LayoutConfig
+from sampletones_application.layout.loader import load_layout_config
+from sampletones_application.paths import (
+ BEHAVIOR_DIRECTORY,
+ LANG_EN,
+ LAYOUT_DIRECTORY,
+ PALETTES_DIRECTORY,
+ THEME_DIRECTORY,
+)
+from sampletones_application.tags.general import SUF_BUTTON, SUF_CHECKBOX, SUF_TEXT
+from sampletones_application.tags.reconstructions import (
+ TAG_RECONSTRUCTIONS_RECONSTRUCTION_CHECKBOX_COLLAPSE_LEVELS,
+ TAG_RECONSTRUCTIONS_RECONSTRUCTION_TEXT_STEMS_EMPTY,
+ TAG_RECONSTRUCTIONS_RECONSTRUCTION_TEXT_STEMS_SETUP,
+)
+from sampletones_application.ui.elements.fonts.registry import FontRegistry
+from sampletones_application.ui.elements.panel import GUIPanel
+from sampletones_application.ui.elements.status import GUIStatusBar
+from sampletones_application.ui.panels.reconstruction.stems import (
+ GUIReconstructionStemsPanel,
+)
+from sampletones_application.ui.themes.registry import ThemeRegistry
+from sampletones_application.ui.themes.setup import setup_themes
+from sampletones_application.utils.palette.catalog import PaletteCatalog
+from sampletones_application.utils.palette.source import PaletteSource
+from sampletones_application.view_model.reconstruction.stems import (
+ ReconstructionStemsViewModel,
+)
+from sampletones_application.view_model.shared.stems import (
+ StemRowViewModel,
+ StemsListViewModel,
+)
+from sampletones_core.constants.enums import ChannelName, HierarchyMode
+
+ROOT_TAG = "test_root"
+CHANNELS: Tuple[ChannelName, ...] = (ChannelName.PULSE1, ChannelName.NOISE)
+
+
+@pytest.fixture
+def layout_config() -> LayoutConfig:
+ source = PaletteSource(PaletteCatalog.load(PALETTES_DIRECTORY).default)
+ return load_layout_config(LAYOUT_DIRECTORY, BEHAVIOR_DIRECTORY, source)
+
+
+@pytest.fixture
+def dpg_context(layout_config: LayoutConfig) -> Iterator[None]:
+ """Stands up the context, fonts, themes, and section-header geometry the panel resolves on construction."""
+ dpg.create_context()
+ FontRegistry.setup(layout_config.fonts)
+ FontRegistry.register_fonts(layout_config.fonts.scale)
+ setup_themes(THEME_DIRECTORY, PaletteSource(PaletteCatalog.load(PALETTES_DIRECTORY).default))
+ GUIPanel.configure_section_header(
+ layout_config.glyphs,
+ layout_config.general.section_header,
+ layout_config.general.collapse,
+ )
+ try:
+ yield
+ finally:
+ ThemeRegistry.clear()
+ dpg.destroy_context()
+
+
+@pytest.fixture
+def panel(dpg_context: None, layout_config: LayoutConfig) -> GUIReconstructionStemsPanel:
+ return GUIReconstructionStemsPanel(
+ stems_layout=layout_config.general.stems,
+ language_manager=LanguageManager(LANG_EN),
+ status_bar=GUIStatusBar(),
+ )
+
+
+def render(panel: GUIReconstructionStemsPanel) -> None:
+ with dpg.window(tag=ROOT_TAG):
+ panel.create_panel(ROOT_TAG)
+
+
+def _row(
+ stem_id: int,
+ *,
+ name: str,
+ channels: FrozenSet[ChannelName] = frozenset(CHANNELS),
+ offered_channels: FrozenSet[ChannelName] = frozenset(CHANNELS),
+ level: int = 0,
+ position: int = 0,
+ level_size: int = 1,
+ level_count: int = 1,
+) -> StemRowViewModel:
+ return StemRowViewModel(
+ key=str(stem_id),
+ path=Path(f"/audio/{name}.wav"),
+ channels=channels,
+ offered_channels=offered_channels,
+ available=True,
+ level=level,
+ position=position,
+ level_size=level_size,
+ level_count=level_count,
+ )
+
+
+def _view_model(
+ *rows: StemRowViewModel,
+ hierarchy_mode: HierarchyMode | None = None,
+ muted_channels: FrozenSet[ChannelName] = frozenset(),
+) -> ReconstructionStemsViewModel:
+ return ReconstructionStemsViewModel(
+ reconstruction_loaded=True,
+ stems=StemsListViewModel(
+ rows=rows,
+ channels_in_play=CHANNELS if rows else (),
+ muted_channels=muted_channels,
+ live=True,
+ collapse_levels=False,
+ ),
+ hierarchy_mode=hierarchy_mode,
+ channel_cap=2 if hierarchy_mode is not None else None,
+ )
+
+
+class TestStemsPanelRows:
+ def test_one_row_per_recording(self, panel: GUIReconstructionStemsPanel) -> None:
+ render(panel)
+
+ panel.update_view(_view_model(_row(0, name="kick"), _row(1, name="snare")))
+
+ stems_list = panel.stems_list
+ assert dpg.get_item_label(stems_list.row_tag("0", SUF_TEXT)) == "kick"
+ assert dpg.get_item_label(stems_list.row_tag("1", SUF_TEXT)) == "snare"
+
+ def test_a_row_offers_a_box_on_every_channel_its_recording_holds(
+ self,
+ panel: GUIReconstructionStemsPanel,
+ ) -> None:
+ render(panel)
+
+ panel.update_view(_view_model(_row(0, name="kick", offered_channels=frozenset({ChannelName.PULSE1}))))
+
+ stems_list = panel.stems_list
+ assert dpg.does_item_exist(stems_list.channel_tag("0", ChannelName.PULSE1))
+ assert not dpg.does_item_exist(stems_list.channel_tag("0", ChannelName.NOISE))
+
+ def test_every_row_carries_a_master_box(self, panel: GUIReconstructionStemsPanel) -> None:
+ render(panel)
+
+ panel.update_view(_view_model(_row(0, name="kick")))
+
+ assert dpg.get_value(panel.stems_list.row_tag("0", SUF_CHECKBOX))
+
+ def test_rows_follow_a_changed_recording_set(self, panel: GUIReconstructionStemsPanel) -> None:
+ render(panel)
+ panel.update_view(_view_model(_row(0, name="kick"), _row(1, name="snare")))
+
+ panel.update_view(_view_model(_row(1, name="snare")))
+
+ stems_list = panel.stems_list
+ assert not dpg.does_item_exist(stems_list.row_tag("0", SUF_TEXT))
+ assert dpg.does_item_exist(stems_list.row_tag("1", SUF_TEXT))
+
+
+class TestStemsPanelSelection:
+ def test_unticking_a_channel_reports_the_recording_and_what_it_keeps(
+ self,
+ panel: GUIReconstructionStemsPanel,
+ ) -> None:
+ reported: List[Tuple[int, FrozenSet[ChannelName]]] = []
+ panel.on_stem_channels_changed = lambda stem_id, channels: reported.append((stem_id, channels))
+ render(panel)
+ panel.update_view(_view_model(_row(0, name="kick")))
+
+ noise_tag = panel.stems_list.channel_tag("0", ChannelName.NOISE)
+ dpg.set_value(noise_tag, False)
+ dpg.get_item_callback(noise_tag)(noise_tag, False, ("0", ChannelName.NOISE))
+
+ assert reported == [(0, frozenset({ChannelName.PULSE1}))]
+
+ def test_unticking_the_master_box_silences_the_recording_everywhere(
+ self,
+ panel: GUIReconstructionStemsPanel,
+ ) -> None:
+ reported: List[Tuple[int, FrozenSet[ChannelName]]] = []
+ panel.on_stem_channels_changed = lambda stem_id, channels: reported.append((stem_id, channels))
+ render(panel)
+ panel.update_view(_view_model(_row(0, name="kick")))
+
+ master_tag = panel.stems_list.row_tag("0", SUF_CHECKBOX)
+ dpg.get_item_callback(master_tag)(master_tag, False, "0")
+
+ assert reported == [(0, frozenset())]
+
+
+class TestStemsPanelRemoval:
+ def test_the_remove_button_reports_the_recording_it_stands_for(
+ self,
+ panel: GUIReconstructionStemsPanel,
+ ) -> None:
+ requested: List[int] = []
+ render(panel)
+ panel.on_stem_remove_requested = requested.append
+ panel.update_view(_view_model(_row(0, name="bass"), _row(1, name="lead")))
+
+ tag = panel.stems_list.row_tag("0", SUF_BUTTON)
+ dpg.get_item_callback(tag)(tag, None, dpg.get_item_user_data(tag))
+
+ assert requested == [0]
+
+ def test_the_last_recording_standing_offers_no_way_out(
+ self,
+ panel: GUIReconstructionStemsPanel,
+ ) -> None:
+ """A reconstruction holds at least one recording, so its row stops answering."""
+ render(panel)
+ panel.update_view(_view_model(_row(0, name="bass")))
+
+ assert not dpg.is_item_enabled(panel.stems_list.row_tag("0", SUF_BUTTON))
+
+
+class TestStemsPanelLevels:
+ def test_the_collapse_toggle_appears_once_there_are_levels_to_collapse(
+ self,
+ panel: GUIReconstructionStemsPanel,
+ ) -> None:
+ render(panel)
+
+ panel.update_view(_view_model(_row(0, name="kick")))
+ assert not dpg.is_item_shown(TAG_RECONSTRUCTIONS_RECONSTRUCTION_CHECKBOX_COLLAPSE_LEVELS)
+
+ panel.update_view(
+ _view_model(
+ _row(0, name="kick", level=0, level_count=2),
+ _row(1, name="snare", level=1, level_count=2),
+ )
+ )
+ assert dpg.is_item_shown(TAG_RECONSTRUCTIONS_RECONSTRUCTION_CHECKBOX_COLLAPSE_LEVELS)
+
+ def test_collapsing_redraws_the_rows_in_one_table(self, panel: GUIReconstructionStemsPanel) -> None:
+ render(panel)
+ panel.update_view(
+ _view_model(
+ _row(0, name="kick", level=0, level_count=2),
+ _row(1, name="snare", level=1, level_count=2),
+ )
+ )
+
+ dpg.set_value(TAG_RECONSTRUCTIONS_RECONSTRUCTION_CHECKBOX_COLLAPSE_LEVELS, True)
+ dpg.get_item_callback(TAG_RECONSTRUCTIONS_RECONSTRUCTION_CHECKBOX_COLLAPSE_LEVELS)(
+ TAG_RECONSTRUCTIONS_RECONSTRUCTION_CHECKBOX_COLLAPSE_LEVELS,
+ True,
+ )
+
+ assert dpg.does_item_exist(panel.stems_list.table_tag)
+ assert dpg.does_item_exist(panel.stems_list.row_tag("1", SUF_TEXT))
+
+ def test_a_reader_who_collapsed_the_levels_keeps_them_collapsed_across_an_edit(
+ self,
+ panel: GUIReconstructionStemsPanel,
+ ) -> None:
+ render(panel)
+ panel.update_view(
+ _view_model(
+ _row(0, name="kick", level=0, level_count=2),
+ _row(1, name="snare", level=1, level_count=2),
+ )
+ )
+ dpg.set_value(TAG_RECONSTRUCTIONS_RECONSTRUCTION_CHECKBOX_COLLAPSE_LEVELS, True)
+
+ panel.update_view(
+ _view_model(
+ _row(0, name="kick", level=0, level_count=2),
+ _row(2, name="hat", level=1, level_count=2),
+ )
+ )
+
+ assert dpg.does_item_exist(panel.stems_list.table_tag)
+
+
+class TestStemsPanelStates:
+ def test_the_setup_line_states_mode_and_cap(self, panel: GUIReconstructionStemsPanel) -> None:
+ render(panel)
+
+ panel.update_view(_view_model(_row(0, name="kick"), hierarchy_mode=HierarchyMode.STRICT))
+
+ assert dpg.is_item_shown(TAG_RECONSTRUCTIONS_RECONSTRUCTION_TEXT_STEMS_SETUP)
+ assert "Strict" in dpg.get_value(TAG_RECONSTRUCTIONS_RECONSTRUCTION_TEXT_STEMS_SETUP)
+ assert "2" in dpg.get_value(TAG_RECONSTRUCTIONS_RECONSTRUCTION_TEXT_STEMS_SETUP)
+
+ def test_the_empty_state_shows_for_a_loaded_reconstruction_without_source(
+ self,
+ panel: GUIReconstructionStemsPanel,
+ ) -> None:
+ render(panel)
+
+ panel.update_view(_view_model())
+
+ assert dpg.is_item_shown(TAG_RECONSTRUCTIONS_RECONSTRUCTION_TEXT_STEMS_EMPTY)
+ assert not dpg.is_item_shown(TAG_RECONSTRUCTIONS_RECONSTRUCTION_TEXT_STEMS_SETUP)
+ assert not dpg.is_item_shown(panel.stems_list.tag)
diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/conftest.py b/tests/unit/sampletones_application/ui/panels/sequencer/conftest.py
index 574a1e837..5f447bfd9 100644
--- a/tests/unit/sampletones_application/ui/panels/sequencer/conftest.py
+++ b/tests/unit/sampletones_application/ui/panels/sequencer/conftest.py
@@ -2,7 +2,7 @@
import pytest
-from sampletones_application.ui.panels.sequencer import tracker
+from sampletones_application.ui.panels.sequencer.tracker import panel as tracker
from sampletones_shared.types.callback import VoidCallback
diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/input/test_grid_input.py b/tests/unit/sampletones_application/ui/panels/sequencer/input/test_grid_input.py
index 510e60bef..a770434a1 100644
--- a/tests/unit/sampletones_application/ui/panels/sequencer/input/test_grid_input.py
+++ b/tests/unit/sampletones_application/ui/panels/sequencer/input/test_grid_input.py
@@ -83,11 +83,11 @@ def test_dropping_a_partial_entry_holds_the_selection(self) -> None:
assert held.pending == ""
def test_cancel_drops_the_selection_and_the_partial_entry(self) -> None:
- cancelled = _GridState(cursor=_Cell(2, 1), pending="5", anchor=_Cell(4, 3)).cancel()
+ canceled = _GridState(cursor=_Cell(2, 1), pending="5", anchor=_Cell(4, 3)).cancel()
- assert cancelled.region is None
- assert cancelled.pending == ""
- assert cancelled.cursor == _Cell(2, 1)
+ assert canceled.region is None
+ assert canceled.pending == ""
+ assert canceled.cursor == _Cell(2, 1)
def test_a_committed_entry_leaves_the_cursor_alone(self) -> None:
settled = _GridState(cursor=_Cell(2, 1), pending="5", anchor=_Cell(4, 3))._after_entry()
diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/input/test_order_input.py b/tests/unit/sampletones_application/ui/panels/sequencer/input/test_order_input.py
index 00e132c8f..549b19912 100644
--- a/tests/unit/sampletones_application/ui/panels/sequencer/input/test_order_input.py
+++ b/tests/unit/sampletones_application/ui/panels/sequencer/input/test_order_input.py
@@ -5,17 +5,17 @@
OrderCursor,
OrderInputState,
)
-from sampletones_core.constants.enums import GeneratorName
+from sampletones_core.constants.enums import ChannelName
POSITION_COUNT = 8
def _state(
- generator: Optional[GeneratorName] = GeneratorName.PULSE1,
+ channel: Optional[ChannelName] = ChannelName.PULSE1,
position: int = 0,
pending: str = "",
) -> OrderInputState:
- return OrderInputState(cursor=OrderCursor(generator, position), pending=pending)
+ return OrderInputState(cursor=OrderCursor(channel, position), pending=pending)
class TestNavigation:
@@ -35,11 +35,11 @@ def test_channel_cycles_master_then_channels_and_wraps(self) -> None:
visited = []
state = OrderInputState(cursor=OrderCursor(CHANNEL_AXIS[0], 0))
for _ in range(len(CHANNEL_AXIS)):
- visited.append(state.cursor.generator)
+ visited.append(state.cursor.channel)
state = state.navigate_channel(1)
assert visited == list(CHANNEL_AXIS)
- assert state.cursor.generator == CHANNEL_AXIS[0]
+ assert state.cursor.channel == CHANNEL_AXIS[0]
class TestSelection:
@@ -62,16 +62,16 @@ def test_extending_leftwards_names_the_same_region_as_rightwards(self) -> None:
assert leftwards == rightwards
def test_extending_channels_reaches_from_master_down(self) -> None:
- extended = _state(generator=None).extend_channel(2)
+ extended = _state(channel=None).extend_channel(2)
region = extended.region
assert region is not None
- assert region.generators == (None, GeneratorName.PULSE1, GeneratorName.PULSE2)
+ assert region.channels == (None, ChannelName.PULSE1, ChannelName.PULSE2)
def test_extending_channels_stops_at_either_end_of_the_axis(self) -> None:
"""A selection covers a run of the table, so its reach stops where plain navigation wraps."""
- first = _state(generator=CHANNEL_AXIS[0]).extend_channel(-1)
- last = _state(generator=CHANNEL_AXIS[-1]).extend_channel(1)
+ first = _state(channel=CHANNEL_AXIS[0]).extend_channel(-1)
+ last = _state(channel=CHANNEL_AXIS[-1]).extend_channel(1)
assert first.cursor == OrderCursor(CHANNEL_AXIS[0], 0)
assert last.cursor == OrderCursor(CHANNEL_AXIS[-1], 0)
@@ -103,26 +103,26 @@ def test_typing_an_index_collapses_the_selection(self) -> None:
assert committed.region is None
def test_cancel_drops_the_selection_and_the_partial_entry(self) -> None:
- cancelled = _state(pending="5").extend_position(1, POSITION_COUNT).cancel()
+ canceled = _state(pending="5").extend_position(1, POSITION_COUNT).cancel()
- assert cancelled.region is None
- assert cancelled.pending == ""
+ assert canceled.region is None
+ assert canceled.pending == ""
class TestTarget:
"""The region a block gesture acts on, which is the selection wherever one has been made."""
def test_a_cell_of_a_table_with_nothing_selected_is_raised_on_itself(self) -> None:
- cell = OrderCursor(GeneratorName.PULSE2, 4)
+ cell = OrderCursor(ChannelName.PULSE2, 4)
- region = _state(GeneratorName.PULSE2, position=4).region_at(cell)
+ region = _state(ChannelName.PULSE2, position=4).region_at(cell)
assert (region.first_position, region.last_position) == (4, 4)
- assert region.generators == (GeneratorName.PULSE2,)
+ assert region.channels == (ChannelName.PULSE2,)
def test_a_cell_of_a_selection_is_raised_on_the_whole_of_it(self) -> None:
selected = _state(position=4).extend_position(2, POSITION_COUNT)
- cell = OrderCursor(GeneratorName.PULSE1, 5)
+ cell = OrderCursor(ChannelName.PULSE1, 5)
assert selected.region_at(cell) == selected.region
@@ -135,17 +135,17 @@ def test_selecting_all_reaches_every_row_and_every_position(self) -> None:
region = selected.region
assert region is not None
- assert region.generators == CHANNEL_AXIS
+ assert region.channels == CHANNEL_AXIS
assert (region.first_position, region.last_position) == (0, POSITION_COUNT - 1)
def test_selecting_a_row_reaches_the_cursor_s_channel_across_the_order(self) -> None:
- cell = OrderCursor(GeneratorName.TRIANGLE, 2)
+ cell = OrderCursor(ChannelName.TRIANGLE, 2)
- selected = _state(GeneratorName.TRIANGLE, position=2).select_row(cell, POSITION_COUNT)
+ selected = _state(ChannelName.TRIANGLE, position=2).select_row(cell, POSITION_COUNT)
region = selected.region
assert region is not None
- assert region.generators == (GeneratorName.TRIANGLE,)
+ assert region.channels == (ChannelName.TRIANGLE,)
assert (region.first_position, region.last_position) == (0, POSITION_COUNT - 1)
def test_the_master_row_is_a_row_like_any_other(self) -> None:
@@ -155,7 +155,7 @@ def test_the_master_row_is_a_row_like_any_other(self) -> None:
region = selected.region
assert region is not None
- assert region.generators == (None,)
+ assert region.channels == (None,)
def test_a_shape_stands_the_cursor_on_the_last_position_it_reaches(self) -> None:
"""A shape ends where the next Shift+arrow starts, which is the far corner it covers."""
diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/input/test_tracker_input.py b/tests/unit/sampletones_application/ui/panels/sequencer/input/test_tracker_input.py
index b4731cb43..a30928472 100644
--- a/tests/unit/sampletones_application/ui/panels/sequencer/input/test_tracker_input.py
+++ b/tests/unit/sampletones_application/ui/panels/sequencer/input/test_tracker_input.py
@@ -3,7 +3,7 @@
from sampletones_application.ui.panels.sequencer.input.tracker import TrackerCursor, TrackerInputState
from sampletones_application.view_model.sequencer.slot import SLOT_COUNT, TrackerSlot
from sampletones_application.view_model.sequencer.subcolumn import SubColumn
-from sampletones_core.constants.enums import GeneratorName
+from sampletones_core.constants.enums import ChannelName
ROW_COUNT = 64
@@ -12,23 +12,23 @@ def _state(
subcolumn: SubColumn,
*,
row: int = 0,
- generator: Optional[GeneratorName] = GeneratorName.PULSE1,
+ channel: Optional[ChannelName] = ChannelName.PULSE1,
pending: str = "",
) -> TrackerInputState:
- return TrackerInputState(cursor=TrackerCursor(row, generator, subcolumn), pending=pending)
+ return TrackerInputState(cursor=TrackerCursor(row, channel, subcolumn), pending=pending)
class TestNoteOffEntry:
def test_minus_in_instrument_emits_note_off(self) -> None:
- state, action = _state(SubColumn.INSTRUMENT).type_char("-")
+ state, action = _state(SubColumn.VOICE).type_char("-")
assert action is not None
assert action.note_off is True
assert action.row == 0
- assert action.generator == GeneratorName.PULSE1
+ assert action.channel == ChannelName.PULSE1
assert state.pending == ""
def test_plus_in_instrument_is_ignored(self) -> None:
- state = _state(SubColumn.INSTRUMENT)
+ state = _state(SubColumn.VOICE)
new_state, action = state.type_char("+")
assert action is None
assert new_state is state
@@ -43,10 +43,10 @@ class TestSelection:
"""Shift-extended moves grow a region from the cell the selection was started on."""
def test_a_state_without_an_anchor_covers_no_region(self) -> None:
- assert _state(SubColumn.INSTRUMENT).region is None
+ assert _state(SubColumn.VOICE).region is None
def test_the_first_extend_anchors_the_cell_it_came_from(self) -> None:
- extended = _state(SubColumn.INSTRUMENT, row=4).extend_row(1, ROW_COUNT)
+ extended = _state(SubColumn.VOICE, row=4).extend_row(1, ROW_COUNT)
region = extended.region
assert region is not None
@@ -54,44 +54,44 @@ def test_the_first_extend_anchors_the_cell_it_came_from(self) -> None:
def test_extending_upwards_names_the_same_region_as_downwards(self) -> None:
"""The bounds are ordered by the region, so the direction of the drag leaves no trace."""
- upwards = _state(SubColumn.INSTRUMENT, row=5).extend_row(-1, ROW_COUNT).region
- downwards = _state(SubColumn.INSTRUMENT, row=4).extend_row(1, ROW_COUNT).region
+ upwards = _state(SubColumn.VOICE, row=5).extend_row(-1, ROW_COUNT).region
+ downwards = _state(SubColumn.VOICE, row=4).extend_row(1, ROW_COUNT).region
assert upwards == downwards
def test_a_further_extend_keeps_the_original_anchor(self) -> None:
- extended = _state(SubColumn.INSTRUMENT, row=4).extend_row(1, ROW_COUNT).extend_row(3, ROW_COUNT)
+ extended = _state(SubColumn.VOICE, row=4).extend_row(1, ROW_COUNT).extend_row(3, ROW_COUNT)
region = extended.region
assert region is not None
assert (region.first_row, region.last_row) == (4, 8)
def test_extending_slots_reaches_across_the_column_boundary(self) -> None:
- extended = _state(SubColumn.VOLUME, generator=None).extend_slot(1)
+ extended = _state(SubColumn.VOLUME, channel=None).extend_slot(1)
region = extended.region
assert region is not None
assert (region.first_slot, region.last_slot) == (2, 3)
assert extended.cursor is not None
- assert extended.cursor.generator is GeneratorName.PULSE1
- assert extended.cursor.subcolumn is SubColumn.INSTRUMENT
+ assert extended.cursor.channel is ChannelName.PULSE1
+ assert extended.cursor.subcolumn is SubColumn.VOICE
def test_extending_slots_stops_at_either_end_of_the_axis(self) -> None:
"""A selection covers a run of the grid, so its reach stops where plain navigation wraps."""
- first = _state(SubColumn.INSTRUMENT, generator=None).extend_slot(-1)
- last = _state(SubColumn.VOLUME, generator=GeneratorName.NOISE).extend_slot(1)
+ first = _state(SubColumn.VOICE, channel=None).extend_slot(-1)
+ last = _state(SubColumn.VOLUME, channel=ChannelName.NOISE).extend_slot(1)
- assert first.cursor == TrackerCursor(0, None, SubColumn.INSTRUMENT)
- assert last.cursor == TrackerCursor(0, GeneratorName.NOISE, SubColumn.VOLUME)
+ assert first.cursor == TrackerCursor(0, None, SubColumn.VOICE)
+ assert last.cursor == TrackerCursor(0, ChannelName.NOISE, SubColumn.VOLUME)
def test_a_plain_move_collapses_the_selection(self) -> None:
- moved = _state(SubColumn.INSTRUMENT, row=4).extend_row(2, ROW_COUNT).navigate_row(1, ROW_COUNT)
+ moved = _state(SubColumn.VOICE, row=4).extend_row(2, ROW_COUNT).navigate_row(1, ROW_COUNT)
assert moved.anchor is None
assert moved.region is None
def test_a_plain_column_move_collapses_the_selection(self) -> None:
- moved = _state(SubColumn.INSTRUMENT).extend_row(2, ROW_COUNT).navigate_column_by(1)
+ moved = _state(SubColumn.VOICE).extend_row(2, ROW_COUNT).navigate_column_by(1)
assert moved.region is None
@@ -110,7 +110,7 @@ def test_typing_a_value_collapses_the_selection(self) -> None:
assert typed.region is None
def test_a_note_off_collapses_the_selection(self) -> None:
- selected = _state(SubColumn.INSTRUMENT, row=4).extend_row(2, ROW_COUNT)
+ selected = _state(SubColumn.VOICE, row=4).extend_row(2, ROW_COUNT)
typed, action = selected.type_char("-")
@@ -119,10 +119,10 @@ def test_a_note_off_collapses_the_selection(self) -> None:
assert typed.region is None
def test_cancel_drops_the_selection_and_the_partial_entry(self) -> None:
- cancelled = _state(SubColumn.VOLUME, pending="5").extend_row(1, ROW_COUNT).cancel()
+ canceled = _state(SubColumn.VOLUME, pending="5").extend_row(1, ROW_COUNT).cancel()
- assert cancelled.region is None
- assert cancelled.pending == ""
+ assert canceled.region is None
+ assert canceled.pending == ""
def test_collapse_keeps_the_cursor_where_it_stands(self) -> None:
selected = _state(SubColumn.TRANSPOSE, row=4).extend_row(2, ROW_COUNT)
@@ -137,16 +137,16 @@ class TestTargetRegion:
"""The region a block gesture acts on, which is the selection wherever one has been made."""
def test_a_cell_of_a_grid_with_nothing_selected_is_raised_on_itself(self) -> None:
- cell = TrackerCursor(4, GeneratorName.PULSE1, SubColumn.TRANSPOSE)
+ cell = TrackerCursor(4, ChannelName.PULSE1, SubColumn.TRANSPOSE)
region = _state(SubColumn.TRANSPOSE, row=4).region_at(cell)
assert (region.first_row, region.last_row) == (4, 4)
- assert region.slots == (TrackerSlot(GeneratorName.PULSE1, SubColumn.TRANSPOSE),)
+ assert region.slots == (TrackerSlot(ChannelName.PULSE1, SubColumn.TRANSPOSE),)
def test_a_cell_of_a_selection_is_raised_on_the_whole_of_it(self) -> None:
- selected = _state(SubColumn.INSTRUMENT, row=4).extend_row(2, ROW_COUNT)
- cell = TrackerCursor(5, GeneratorName.PULSE1, SubColumn.INSTRUMENT)
+ selected = _state(SubColumn.VOICE, row=4).extend_row(2, ROW_COUNT)
+ cell = TrackerCursor(5, ChannelName.PULSE1, SubColumn.VOICE)
assert selected.region_at(cell) == selected.region
@@ -163,65 +163,65 @@ def test_selecting_all_reaches_every_row_and_every_slot(self) -> None:
assert (region.first_slot, region.last_slot) == (0, SLOT_COUNT - 1)
def test_selecting_a_column_reaches_the_cursor_s_channel_and_its_subcolumns(self) -> None:
- cell = TrackerCursor(4, GeneratorName.TRIANGLE, SubColumn.TRANSPOSE)
+ cell = TrackerCursor(4, ChannelName.TRIANGLE, SubColumn.TRANSPOSE)
selected = _state(SubColumn.TRANSPOSE, row=4).select_column(cell, ROW_COUNT)
region = selected.region
assert region is not None
assert (region.first_row, region.last_row) == (0, ROW_COUNT - 1)
- assert region.slots == tuple(TrackerSlot(GeneratorName.TRIANGLE, subcolumn) for subcolumn in SubColumn)
+ assert region.slots == tuple(TrackerSlot(ChannelName.TRIANGLE, subcolumn) for subcolumn in SubColumn)
def test_the_sample_column_is_a_column_like_any_other(self) -> None:
cell = TrackerCursor(4, None, SubColumn.VOLUME)
- selected = _state(SubColumn.VOLUME, row=4, generator=None).select_column(cell, ROW_COUNT)
+ selected = _state(SubColumn.VOLUME, row=4, channel=None).select_column(cell, ROW_COUNT)
region = selected.region
assert region is not None
assert region.columns == (None,)
def test_selecting_a_subcolumn_reaches_the_one_slot_the_cursor_stands_on(self) -> None:
- cell = TrackerCursor(4, GeneratorName.NOISE, SubColumn.VOLUME)
+ cell = TrackerCursor(4, ChannelName.NOISE, SubColumn.VOLUME)
- selected = _state(SubColumn.VOLUME, row=4, generator=GeneratorName.NOISE).select_subcolumn(cell, ROW_COUNT)
+ selected = _state(SubColumn.VOLUME, row=4, channel=ChannelName.NOISE).select_subcolumn(cell, ROW_COUNT)
region = selected.region
assert region is not None
assert (region.first_row, region.last_row) == (0, ROW_COUNT - 1)
- assert region.slots == (TrackerSlot(GeneratorName.NOISE, SubColumn.VOLUME),)
+ assert region.slots == (TrackerSlot(ChannelName.NOISE, SubColumn.VOLUME),)
def test_a_shape_stands_the_cursor_on_the_last_row_it_reaches(self) -> None:
"""A shape ends where the next Shift+arrow starts, which is the far corner it covers."""
- cell = TrackerCursor(4, GeneratorName.PULSE1, SubColumn.INSTRUMENT)
+ cell = TrackerCursor(4, ChannelName.PULSE1, SubColumn.VOICE)
- selected = _state(SubColumn.INSTRUMENT, row=4).select_column(cell, ROW_COUNT)
+ selected = _state(SubColumn.VOICE, row=4).select_column(cell, ROW_COUNT)
- assert selected.cursor == TrackerCursor(ROW_COUNT - 1, GeneratorName.PULSE1, SubColumn.VOLUME)
- assert selected.anchor == TrackerCursor(0, GeneratorName.PULSE1, SubColumn.INSTRUMENT)
+ assert selected.cursor == TrackerCursor(ROW_COUNT - 1, ChannelName.PULSE1, SubColumn.VOLUME)
+ assert selected.anchor == TrackerCursor(0, ChannelName.PULSE1, SubColumn.VOICE)
def test_a_frame_holding_no_rows_selects_nothing(self) -> None:
- state = _state(SubColumn.INSTRUMENT)
+ state = _state(SubColumn.VOICE)
assert state.select_all(0) is state
class TestColumnNavigation:
def test_tab_preserves_subcolumn(self) -> None:
- state = _state(SubColumn.VOLUME, generator=GeneratorName.PULSE1)
+ state = _state(SubColumn.VOLUME, channel=ChannelName.PULSE1)
moved = state.navigate_column_by(1)
assert moved.cursor is not None
- assert moved.cursor.generator != GeneratorName.PULSE1
+ assert moved.cursor.channel != ChannelName.PULSE1
assert moved.cursor.subcolumn is SubColumn.VOLUME
def test_shift_tab_preserves_subcolumn(self) -> None:
- state = _state(SubColumn.TRANSPOSE, generator=GeneratorName.PULSE1)
+ state = _state(SubColumn.TRANSPOSE, channel=ChannelName.PULSE1)
moved = state.navigate_column_by(-1)
assert moved.cursor is not None
assert moved.cursor.subcolumn is SubColumn.TRANSPOSE
def test_tab_preserves_row(self) -> None:
- state = _state(SubColumn.VOLUME, row=5, generator=GeneratorName.PULSE1)
+ state = _state(SubColumn.VOLUME, row=5, channel=ChannelName.PULSE1)
moved = state.navigate_column_by(1)
assert moved.cursor is not None
assert moved.cursor.row == 5
diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/test_block_keys.py b/tests/unit/sampletones_application/ui/panels/sequencer/test_block_keys.py
index b82dcdae0..e7a52d782 100644
--- a/tests/unit/sampletones_application/ui/panels/sequencer/test_block_keys.py
+++ b/tests/unit/sampletones_application/ui/panels/sequencer/test_block_keys.py
@@ -5,7 +5,6 @@
from sampletones_application.constants.sequencer import CHANNEL_AXIS
from sampletones_application.ui.elements.table.cells import EditableCells
-from sampletones_application.ui.panels.sequencer import tracker as tracker_module
from sampletones_application.ui.panels.sequencer.grid.gestures import BlockGestures
from sampletones_application.ui.panels.sequencer.input.order import (
OrderCursor,
@@ -13,8 +12,10 @@
)
from sampletones_application.ui.panels.sequencer.input.target import OrderTarget, TrackerTarget
from sampletones_application.ui.panels.sequencer.input.tracker import TrackerCursor, TrackerInputState
-from sampletones_application.ui.panels.sequencer.order import GUISequencerOrderPanel
-from sampletones_application.ui.panels.sequencer.tracker import GUISequencerTrackerPanel
+from sampletones_application.ui.panels.sequencer.order.panel import GUISequencerOrderPanel
+from sampletones_application.ui.panels.sequencer.tracker import adjust
+from sampletones_application.ui.panels.sequencer.tracker import panel as tracker_module
+from sampletones_application.ui.panels.sequencer.tracker.panel import GUISequencerTrackerPanel
from sampletones_application.utils.gui.keyboard.combination import KeyCombination
from sampletones_application.utils.gui.keyboard.event import KeyEvent
from sampletones_application.view_model.sequencer.region import (
@@ -25,7 +26,7 @@
)
from sampletones_application.view_model.sequencer.slot import TrackerSlot
from sampletones_application.view_model.sequencer.subcolumn import SubColumn
-from sampletones_core.constants.enums import GeneratorName
+from sampletones_core.constants.enums import ChannelName
from sampletones_shared.constants.music import OCTAVE_SEMITONES, SEMITONE_STEP
from tests.suite.grid import (
ORDER_BLOCK_SHORTCUTS,
@@ -39,7 +40,7 @@
POSITION_COUNT = 8
CURSOR_POSITION = 2
MASTER_ROW = CHANNEL_AXIS.index(None)
-PULSE1_ROW = CHANNEL_AXIS.index(GeneratorName.PULSE1)
+PULSE1_ROW = CHANNEL_AXIS.index(ChannelName.PULSE1)
@dataclass
@@ -51,7 +52,7 @@ class Gestures:
cut: List[TrackerRegion] = field(default_factory=list)
deleted: List[TrackerRegion] = field(default_factory=list)
pasted: List[TrackerCell] = field(default_factory=list)
- cleared: List[Tuple[int, Optional[GeneratorName]]] = field(default_factory=list)
+ cleared: List[Tuple[int, Optional[ChannelName]]] = field(default_factory=list)
transposed: List[Tuple[TrackerRegion, int]] = field(default_factory=list)
volume_shifted: List[Tuple[TrackerRegion, int]] = field(default_factory=list)
@@ -64,7 +65,7 @@ class OrderGestures:
cut: List[OrderRegion] = field(default_factory=list)
deleted: List[OrderRegion] = field(default_factory=list)
pasted: List[OrderCell] = field(default_factory=list)
- cleared: List[Tuple[GeneratorName, int, Optional[int]]] = field(default_factory=list)
+ cleared: List[Tuple[ChannelName, int, Optional[int]]] = field(default_factory=list)
def _press(text: str) -> KeyEvent:
@@ -77,8 +78,8 @@ def _panel(
monkeypatch: pytest.MonkeyPatch,
gestures: Gestures,
*,
- generator: Optional[GeneratorName] = GeneratorName.PULSE1,
- subcolumn: SubColumn = SubColumn.INSTRUMENT,
+ channel: Optional[ChannelName] = ChannelName.PULSE1,
+ subcolumn: SubColumn = SubColumn.VOICE,
) -> GUISequencerTrackerPanel:
"""A tracker panel reporting the gestures it fires, with its grid left unbuilt.
@@ -86,15 +87,16 @@ def _panel(
each gesture is read from what its hook receives.
"""
panel = GUISequencerTrackerPanel.__new__(GUISequencerTrackerPanel)
+ panel._cell_kinds = {}
panel._shortcuts = shipped_source()
- panel._input_state = TrackerInputState(cursor=TrackerCursor(CURSOR_ROW, generator, subcolumn))
+ panel._input_state = TrackerInputState(cursor=TrackerCursor(CURSOR_ROW, channel, subcolumn))
panel._current_row_count = ROW_COUNT
panel._editable_cells = EditableCells()
panel.on_copy_block = gestures.copied.append
panel.on_cut_block = gestures.cut.append
panel.on_delete_block = gestures.deleted.append
panel.on_paste_block = gestures.pasted.append
- panel.on_clear_row = lambda row, generator_name: gestures.cleared.append((row, generator_name))
+ panel.on_clear_row = lambda row, channel_name: gestures.cleared.append((row, channel_name))
panel.on_adjust_transpose = lambda region, delta: gestures.transposed.append((region, delta))
panel.on_adjust_volume = lambda region, delta: gestures.volume_shifted.append((region, delta))
panel.can_paste_block = lambda: True
@@ -108,12 +110,12 @@ def _order_panel(
monkeypatch: pytest.MonkeyPatch,
gestures: OrderGestures,
*,
- generator: Optional[GeneratorName] = GeneratorName.PULSE1,
+ channel: Optional[ChannelName] = ChannelName.PULSE1,
) -> GUISequencerOrderPanel:
"""An order panel reporting the gestures it fires, with its table left unbuilt."""
panel = GUISequencerOrderPanel.__new__(GUISequencerOrderPanel)
panel._shortcuts = shipped_source()
- panel._input_state = OrderInputState(cursor=OrderCursor(generator, CURSOR_POSITION))
+ panel._input_state = OrderInputState(cursor=OrderCursor(channel, CURSOR_POSITION))
panel._position_count = POSITION_COUNT
panel.on_copy_block = gestures.copied.append
panel.on_cut_block = gestures.cut.append
@@ -152,7 +154,7 @@ def test_a_cursor_alone_copies_the_cell_it_stands_on(
assert panel._on_key_pressed(_press("Ctrl+C")) is True
assert gestures.copied[-1].rows == range(CURSOR_ROW, CURSOR_ROW + 1)
- assert gestures.copied[-1].slots == (TrackerSlot(GeneratorName.PULSE1, SubColumn.VOLUME),)
+ assert gestures.copied[-1].slots == (TrackerSlot(ChannelName.PULSE1, SubColumn.VOLUME),)
def test_a_grid_with_no_cursor_copies_nothing(self, monkeypatch: pytest.MonkeyPatch) -> None:
gestures = Gestures()
@@ -192,17 +194,17 @@ def test_a_paste_names_the_cell_the_cursor_stands_on(
panel = _panel(monkeypatch, gestures, subcolumn=SubColumn.VOLUME)
assert panel._on_key_pressed(_press("Ctrl+V")) is True
- assert gestures.pasted == [TrackerCell(row=CURSOR_ROW, generator=GeneratorName.PULSE1)]
+ assert gestures.pasted == [TrackerCell(row=CURSOR_ROW, channel=ChannelName.PULSE1)]
def test_the_sample_column_is_a_cell_a_block_lands_on(
self,
monkeypatch: pytest.MonkeyPatch,
) -> None:
gestures = Gestures()
- panel = _panel(monkeypatch, gestures, generator=None)
+ panel = _panel(monkeypatch, gestures, channel=None)
assert panel._on_key_pressed(_press("Ctrl+V")) is True
- assert gestures.pasted == [TrackerCell(row=CURSOR_ROW, generator=None)]
+ assert gestures.pasted == [TrackerCell(row=CURSOR_ROW, channel=None)]
class TestTrackerDeleteKey:
@@ -232,7 +234,7 @@ def test_a_cursor_alone_keeps_clearing_the_cell_it_stands_on(
assert panel._on_key_pressed(_press("Del")) is True
assert gestures.deleted == []
- assert gestures.cleared == [(CURSOR_ROW, GeneratorName.PULSE1)]
+ assert gestures.cleared == [(CURSOR_ROW, ChannelName.PULSE1)]
class TestTrackerAdjustKeys:
@@ -263,8 +265,8 @@ def test_a_cursor_alone_shifts_the_cell_it_stands_on(self, monkeypatch: pytest.M
assert panel._on_key_pressed(_press("Alt+Down")) is True
region, delta = gestures.volume_shifted[-1]
assert region.rows == range(CURSOR_ROW, CURSOR_ROW + 1)
- assert region.slots == (TrackerSlot(GeneratorName.PULSE1, SubColumn.VOLUME),)
- assert delta == -tracker_module.VOLUME_FINE_STEP
+ assert region.slots == (TrackerSlot(ChannelName.PULSE1, SubColumn.VOLUME),)
+ assert delta == -adjust.VOLUME_FINE_STEP
def test_shift_makes_the_step_the_bigger_one(self, monkeypatch: pytest.MonkeyPatch) -> None:
gestures = Gestures()
@@ -273,7 +275,7 @@ def test_shift_makes_the_step_the_bigger_one(self, monkeypatch: pytest.MonkeyPat
assert panel._on_key_pressed(_press("Ctrl+Shift+Up")) is True
assert panel._on_key_pressed(_press("Alt+Shift+Up")) is True
assert gestures.transposed[-1][1] == OCTAVE_SEMITONES
- assert gestures.volume_shifted[-1][1] == tracker_module.VOLUME_COARSE_STEP
+ assert gestures.volume_shifted[-1][1] == adjust.VOLUME_COARSE_STEP
def test_a_grid_with_no_cursor_shifts_nothing(self, monkeypatch: pytest.MonkeyPatch) -> None:
gestures = Gestures()
@@ -305,7 +307,7 @@ def test_a_cursor_alone_copies_the_cell_it_stands_on(
monkeypatch: pytest.MonkeyPatch,
) -> None:
gestures = OrderGestures()
- panel = _order_panel(monkeypatch, gestures, generator=None)
+ panel = _order_panel(monkeypatch, gestures, channel=None)
assert panel._on_key_pressed(_press("Ctrl+C")) is True
assert gestures.copied == [
@@ -353,17 +355,17 @@ def test_a_paste_names_the_cell_the_cursor_stands_on(
panel = _order_panel(monkeypatch, gestures)
assert panel._on_key_pressed(_press("Ctrl+V")) is True
- assert gestures.pasted == [OrderCell(generator=GeneratorName.PULSE1, position=CURSOR_POSITION)]
+ assert gestures.pasted == [OrderCell(channel=ChannelName.PULSE1, position=CURSOR_POSITION)]
def test_the_master_row_is_a_cell_a_block_lands_on(
self,
monkeypatch: pytest.MonkeyPatch,
) -> None:
gestures = OrderGestures()
- panel = _order_panel(monkeypatch, gestures, generator=None)
+ panel = _order_panel(monkeypatch, gestures, channel=None)
assert panel._on_key_pressed(_press("Ctrl+V")) is True
- assert gestures.pasted == [OrderCell(generator=None, position=CURSOR_POSITION)]
+ assert gestures.pasted == [OrderCell(channel=None, position=CURSOR_POSITION)]
class TestOrderDeleteKey:
@@ -393,4 +395,4 @@ def test_a_cursor_alone_keeps_clearing_the_cell_it_stands_on(
assert panel._on_key_pressed(_press("Del")) is True
assert gestures.deleted == []
- assert gestures.cleared == [(GeneratorName.PULSE1, CURSOR_POSITION, None)]
+ assert gestures.cleared == [(ChannelName.PULSE1, CURSOR_POSITION, None)]
diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/test_block_menu.py b/tests/unit/sampletones_application/ui/panels/sequencer/test_block_menu.py
index 53e5b2142..dfe784bbc 100644
--- a/tests/unit/sampletones_application/ui/panels/sequencer/test_block_menu.py
+++ b/tests/unit/sampletones_application/ui/panels/sequencer/test_block_menu.py
@@ -5,9 +5,11 @@
import pytest
+from sampletones_application.categories.elements.sequencer import SequencerOrderElements, SequencerTrackerElements
+from sampletones_application.categories.hierarchy import Page, Panel, TextType
+from sampletones_application.categories.manager import LanguageManager
from sampletones_application.constants.sequencer import CHANNEL_AXIS
-from sampletones_application.ui.panels.sequencer import order as order_module
-from sampletones_application.ui.panels.sequencer import tracker as tracker_module
+from sampletones_application.paths import LANG_EN
from sampletones_application.ui.panels.sequencer.grid.gestures import BlockGestures
from sampletones_application.ui.panels.sequencer.grid.surface import clipboard as clipboard_module
from sampletones_application.ui.panels.sequencer.input.order import (
@@ -16,6 +18,11 @@
)
from sampletones_application.ui.panels.sequencer.input.target import OrderTarget, TrackerTarget
from sampletones_application.ui.panels.sequencer.input.tracker import TrackerCursor, TrackerInputState
+from sampletones_application.ui.panels.sequencer.order import panel as order_module
+from sampletones_application.ui.panels.sequencer.order.menu import OrderMenu
+from sampletones_application.ui.panels.sequencer.tracker import adjust
+from sampletones_application.ui.panels.sequencer.tracker import panel as tracker_module
+from sampletones_application.ui.panels.sequencer.tracker.menu import TrackerMenu
from sampletones_application.view_model.sequencer.region import (
OrderCell,
OrderRegion,
@@ -24,7 +31,7 @@
)
from sampletones_application.view_model.sequencer.slot import SLOT_COUNT, TrackerSlot, slot_from_flat
from sampletones_application.view_model.sequencer.subcolumn import SubColumn
-from sampletones_core.constants.enums import GeneratorName
+from sampletones_core.constants.enums import ChannelName
from tests.suite.grid import (
ORDER_BLOCK_SHORTCUTS,
TRACKER_BLOCK_SHORTCUTS,
@@ -47,7 +54,7 @@
SELECT_SUBCOLUMN_ITEM = 2
SELECT_ROW_ITEM = 1
-PULSE1_ROW = CHANNEL_AXIS.index(GeneratorName.PULSE1)
+PULSE1_ROW = CHANNEL_AXIS.index(ChannelName.PULSE1)
@dataclass
@@ -97,8 +104,8 @@ def add_menu_item(self, **kwargs: Any) -> int:
"select_column",
"select_subcolumn",
"note_off",
- "set_instrument",
- "no_samples",
+ "set_voice",
+ "no_voices",
"clear_subcolumn",
"clear_cell",
"clear_row",
@@ -128,10 +135,22 @@ def _labels(panel: Any, names: Tuple[str, ...]) -> None:
def _adjust_labels(panel: Any) -> None:
"""Gives the panel the words its transpose and volume items print, each reading as its element."""
panel._lbl_adjust = {
- element: element.value for element, _, _ in (*tracker_module.TRANSPOSE_ACTIONS, *tracker_module.VOLUME_ACTIONS)
+ element: element.value for element, _, _ in (*adjust.TRANSPOSE_ACTIONS, *adjust.VOLUME_ACTIONS)
}
+def _order_words(*elements: SequencerOrderElements) -> List[str]:
+ """The words the order menu prints for these actions, read from the language file it reads."""
+ language_manager = LanguageManager(LANG_EN)
+ return [language_manager[Page.SEQUENCER, Panel.ORDER, TextType.LABEL, element] for element in elements]
+
+
+def _tracker_words(*elements: SequencerTrackerElements) -> List[str]:
+ """The words the tracker menu prints for these actions, read from the language file it reads."""
+ language_manager = LanguageManager(LANG_EN)
+ return [language_manager[Page.SEQUENCER, Panel.TRACKER, TextType.LABEL, element] for element in elements]
+
+
def _tracker_panel(
gestures: Gestures,
*,
@@ -151,6 +170,11 @@ def _tracker_panel(
panel.can_paste_block = lambda: can_paste
panel._blocks = BlockGestures(grid=panel)
attach_edit_surface(panel, TRACKER_BLOCK_SHORTCUTS, TrackerTarget)
+ panel._menu = TrackerMenu(
+ panel,
+ language_manager=LanguageManager(LANG_EN),
+ shortcut_source=panel._shortcuts,
+ )
return panel
@@ -172,6 +196,11 @@ def _order_panel(
panel.can_paste_block = lambda: can_paste
panel._blocks = BlockGestures(grid=panel)
attach_edit_surface(panel, ORDER_BLOCK_SHORTCUTS, OrderTarget)
+ panel._menu = OrderMenu(
+ panel,
+ language_manager=LanguageManager(LANG_EN),
+ shortcut_source=panel._shortcuts,
+ )
return panel
@@ -204,14 +233,14 @@ def order_recorder(monkeypatch: pytest.MonkeyPatch) -> _MenuRecorder:
return _record_into(monkeypatch, order_module)
-def _tracker_cell(generator: Optional[GeneratorName]) -> TrackerCursor:
+def _tracker_cell(channel: Optional[ChannelName]) -> TrackerCursor:
"""The clicked cell the tracker item tests raise their menu on."""
- return TrackerCursor(CLICKED_ROW, generator, SubColumn.INSTRUMENT)
+ return TrackerCursor(CLICKED_ROW, channel, SubColumn.VOICE)
-def _order_cell(generator: Optional[GeneratorName]) -> OrderCursor:
+def _order_cell(channel: Optional[ChannelName]) -> OrderCursor:
"""The clicked cell the order item tests raise their menu on."""
- return OrderCursor(generator, CLICKED_POSITION)
+ return OrderCursor(channel, CLICKED_POSITION)
def _tracker_selections(
@@ -219,7 +248,7 @@ def _tracker_selections(
panel: tracker_module.GUISequencerTrackerPanel,
) -> List[TrackerInputState]:
"""The states a select item applies, on a grid holding a cursor and the rows to reach."""
- panel._input_state = TrackerInputState(cursor=_tracker_cell(GeneratorName.PULSE1))
+ panel._input_state = TrackerInputState(cursor=_tracker_cell(ChannelName.PULSE1))
panel._current_row_count = ROW_COUNT
states: List[TrackerInputState] = []
monkeypatch.setattr(panel, "_apply_state", states.append)
@@ -232,7 +261,7 @@ def _order_selections(
panel: order_module.GUISequencerOrderPanel,
) -> List[OrderInputState]:
"""The states a select item applies, on a table holding a cursor and the positions to reach."""
- panel._input_state = OrderInputState(cursor=_order_cell(GeneratorName.PULSE1))
+ panel._input_state = OrderInputState(cursor=_order_cell(ChannelName.PULSE1))
states: List[OrderInputState] = []
monkeypatch.setattr(panel, "_apply_state", lambda state, notify=True: states.append(state))
return states
@@ -240,13 +269,13 @@ def _order_selections(
def _selected_tracker_state() -> TrackerInputState:
"""A selection running from the clicked row down two rows, over Pulse 1's whole cell."""
- state = TrackerInputState(cursor=TrackerCursor(CLICKED_ROW, GeneratorName.PULSE1, SubColumn.INSTRUMENT))
+ state = TrackerInputState(cursor=TrackerCursor(CLICKED_ROW, ChannelName.PULSE1, SubColumn.VOICE))
return state.extend_row(2, ROW_COUNT).extend_slot(2)
def _selected_order_state() -> OrderInputState:
"""A selection running from the clicked position across two positions of Pulse 1's row."""
- state = OrderInputState(cursor=OrderCursor(GeneratorName.PULSE1, CLICKED_POSITION))
+ state = OrderInputState(cursor=OrderCursor(ChannelName.PULSE1, CLICKED_POSITION))
return state.extend_position(2, POSITION_COUNT)
@@ -258,7 +287,7 @@ def test_a_menu_raised_inside_a_selection_acts_on_the_whole_of_it(self) -> None:
target = panel._surface.target_at(
TrackerCursor(
CLICKED_ROW + 1,
- GeneratorName.PULSE1,
+ ChannelName.PULSE1,
SubColumn.TRANSPOSE,
)
)
@@ -272,7 +301,7 @@ def test_a_menu_raised_outside_a_selection_acts_on_the_clicked_cell(self) -> Non
target = panel._surface.target_at(
TrackerCursor(
CLICKED_ROW,
- GeneratorName.TRIANGLE,
+ ChannelName.TRIANGLE,
SubColumn.VOLUME,
)
)
@@ -280,17 +309,17 @@ def test_a_menu_raised_outside_a_selection_acts_on_the_clicked_cell(self) -> Non
assert target.region == TrackerRegion(
first_row=CLICKED_ROW,
last_row=CLICKED_ROW,
- first_slot=TrackerSlot(GeneratorName.TRIANGLE, SubColumn.VOLUME).flat_index,
- last_slot=TrackerSlot(GeneratorName.TRIANGLE, SubColumn.VOLUME).flat_index,
+ first_slot=TrackerSlot(ChannelName.TRIANGLE, SubColumn.VOLUME).flat_index,
+ last_slot=TrackerSlot(ChannelName.TRIANGLE, SubColumn.VOLUME).flat_index,
)
def test_a_menu_raised_with_nothing_selected_acts_on_the_clicked_cell(self) -> None:
panel = _tracker_panel(Gestures())
- target = panel._surface.target_at(TrackerCursor(CLICKED_ROW, None, SubColumn.INSTRUMENT))
+ target = panel._surface.target_at(TrackerCursor(CLICKED_ROW, None, SubColumn.VOICE))
assert target.region.rows == range(CLICKED_ROW, CLICKED_ROW + 1)
- assert target.region.slots == (TrackerSlot(None, SubColumn.INSTRUMENT),)
+ assert target.region.slots == (TrackerSlot(None, SubColumn.VOICE),)
def test_the_cursor_resolves_to_the_selection_it_ends(self) -> None:
"""The menu bar asks for the cursor's own target, which is the standing selection."""
@@ -307,7 +336,7 @@ def test_a_grid_holding_no_cursor_names_no_target(self) -> None:
def test_the_cursor_resolves_to_its_own_cell_with_nothing_selected(self) -> None:
panel = _tracker_panel(Gestures())
- cursor = TrackerCursor(CLICKED_ROW, GeneratorName.NOISE, SubColumn.VOLUME)
+ cursor = TrackerCursor(CLICKED_ROW, ChannelName.NOISE, SubColumn.VOLUME)
panel._input_state = TrackerInputState(cursor=cursor)
target = panel._surface.cursor_target()
@@ -316,10 +345,10 @@ def test_the_cursor_resolves_to_its_own_cell_with_nothing_selected(self) -> None
assert target.region == TrackerRegion(
first_row=CLICKED_ROW,
last_row=CLICKED_ROW,
- first_slot=TrackerSlot(GeneratorName.NOISE, SubColumn.VOLUME).flat_index,
- last_slot=TrackerSlot(GeneratorName.NOISE, SubColumn.VOLUME).flat_index,
+ first_slot=TrackerSlot(ChannelName.NOISE, SubColumn.VOLUME).flat_index,
+ last_slot=TrackerSlot(ChannelName.NOISE, SubColumn.VOLUME).flat_index,
)
- assert target.anchor == TrackerCell(row=CLICKED_ROW, generator=GeneratorName.NOISE)
+ assert target.anchor == TrackerCell(row=CLICKED_ROW, channel=ChannelName.NOISE)
class TestTrackerMenuItems:
@@ -332,7 +361,7 @@ def test_the_items_hand_out_the_block_the_menu_was_raised_on(
panel._input_state = _selected_tracker_state()
selection = panel._input_state.region
- panel._surface.add_block_items(panel._surface.target_at(_tracker_cell(GeneratorName.PULSE1)))
+ panel._surface.add_block_items(panel._surface.target_at(_tracker_cell(ChannelName.PULSE1)))
for item in tracker_recorder.items:
item.callback()
@@ -349,19 +378,19 @@ def test_a_paste_anchors_at_the_clicked_cell(self, tracker_recorder: _MenuRecord
panel._surface.target_at(
TrackerCursor(
CLICKED_ROW,
- GeneratorName.NOISE,
+ ChannelName.NOISE,
SubColumn.VOLUME,
)
)
)
tracker_recorder.items[PASTE_ITEM].callback()
- assert gestures.pasted == [TrackerCell(row=CLICKED_ROW, generator=GeneratorName.NOISE)]
+ assert gestures.pasted == [TrackerCell(row=CLICKED_ROW, channel=ChannelName.NOISE)]
def test_paste_awaits_a_copy(self, tracker_recorder: _MenuRecorder) -> None:
panel = _tracker_panel(Gestures(), can_paste=False)
- panel._surface.add_block_items(panel._surface.target_at(_tracker_cell(GeneratorName.PULSE1)))
+ panel._surface.add_block_items(panel._surface.target_at(_tracker_cell(ChannelName.PULSE1)))
assert tracker_recorder.items[PASTE_ITEM].enabled is False
assert [item.enabled for item in tracker_recorder.items] == [True, True, False, True]
@@ -372,7 +401,7 @@ def test_the_section_reads_as_the_four_clipboard_actions(
) -> None:
panel = _tracker_panel(Gestures())
- panel._surface.add_block_items(panel._surface.target_at(_tracker_cell(GeneratorName.PULSE1)))
+ panel._surface.add_block_items(panel._surface.target_at(_tracker_cell(ChannelName.PULSE1)))
assert [item.label for item in tracker_recorder.items] == ["Copy", "Cut", "Paste", "Delete"]
@@ -382,7 +411,7 @@ def test_a_menu_raised_inside_a_selection_acts_on_the_whole_of_it(self) -> None:
panel = _order_panel(Gestures())
panel._input_state = _selected_order_state()
- target = panel._surface.target_at(OrderCursor(GeneratorName.PULSE1, CLICKED_POSITION + 1))
+ target = panel._surface.target_at(OrderCursor(ChannelName.PULSE1, CLICKED_POSITION + 1))
assert target.region == panel._input_state.region
@@ -402,7 +431,7 @@ def test_a_menu_raised_outside_a_selection_acts_on_the_clicked_cell(self) -> Non
def test_a_menu_raised_with_nothing_selected_acts_on_the_clicked_cell(self) -> None:
panel = _order_panel(Gestures())
- target = panel._surface.target_at(_order_cell(GeneratorName.PULSE1))
+ target = panel._surface.target_at(_order_cell(ChannelName.PULSE1))
assert target.region == OrderRegion(
first_row=PULSE1_ROW,
@@ -426,7 +455,7 @@ def test_a_table_holding_no_cursor_names_no_target(self) -> None:
def test_the_cursor_resolves_to_its_own_cell_with_nothing_selected(self) -> None:
panel = _order_panel(Gestures())
- cursor = OrderCursor(GeneratorName.PULSE1, CLICKED_POSITION)
+ cursor = OrderCursor(ChannelName.PULSE1, CLICKED_POSITION)
panel._input_state = OrderInputState(cursor=cursor)
target = panel._surface.cursor_target()
@@ -438,7 +467,7 @@ def test_the_cursor_resolves_to_its_own_cell_with_nothing_selected(self) -> None
first_position=CLICKED_POSITION,
last_position=CLICKED_POSITION,
)
- assert target.anchor == OrderCell(generator=GeneratorName.PULSE1, position=CLICKED_POSITION)
+ assert target.anchor == OrderCell(channel=ChannelName.PULSE1, position=CLICKED_POSITION)
class TestOrderMenuItems:
@@ -451,7 +480,7 @@ def test_the_items_hand_out_the_block_the_menu_was_raised_on(
panel._input_state = _selected_order_state()
selection = panel._input_state.region
- panel._surface.add_block_items(panel._surface.target_at(_order_cell(GeneratorName.PULSE1)))
+ panel._surface.add_block_items(panel._surface.target_at(_order_cell(ChannelName.PULSE1)))
for item in order_recorder.items:
item.callback()
@@ -466,12 +495,12 @@ def test_a_paste_anchors_at_the_clicked_cell(self, order_recorder: _MenuRecorder
panel._surface.add_block_items(panel._surface.target_at(_order_cell(None)))
order_recorder.items[PASTE_ITEM].callback()
- assert gestures.pasted == [OrderCell(generator=None, position=CLICKED_POSITION)]
+ assert gestures.pasted == [OrderCell(channel=None, position=CLICKED_POSITION)]
def test_paste_awaits_a_copy(self, order_recorder: _MenuRecorder) -> None:
panel = _order_panel(Gestures(), can_paste=False)
- panel._surface.add_block_items(panel._surface.target_at(_order_cell(GeneratorName.PULSE1)))
+ panel._surface.add_block_items(panel._surface.target_at(_order_cell(ChannelName.PULSE1)))
assert order_recorder.items[PASTE_ITEM].enabled is False
assert [item.enabled for item in order_recorder.items] == [True, True, False, True]
@@ -482,7 +511,7 @@ def test_the_section_reads_as_the_four_clipboard_actions(
) -> None:
panel = _order_panel(Gestures())
- panel._surface.add_block_items(panel._surface.target_at(_order_cell(GeneratorName.PULSE1)))
+ panel._surface.add_block_items(panel._surface.target_at(_order_cell(ChannelName.PULSE1)))
assert [item.label for item in order_recorder.items] == ["Copy", "Cut", "Paste", "Delete"]
@@ -496,12 +525,16 @@ def test_the_tracker_action_set_leads_with_the_shapes_a_selection_takes(
) -> None:
panel = _tracker_panel(Gestures())
- panel.add_action_items(panel._surface.target_at(_tracker_cell(GeneratorName.PULSE1)))
+ panel.add_action_items(panel._surface.target_at(_tracker_cell(ChannelName.PULSE1)))
labels = [item.label for item in tracker_recorder.items]
- assert labels[:3] == ["select_all", "select_column", "select_subcolumn"]
+ assert labels[:3] == _tracker_words(
+ SequencerTrackerElements.CONTEXT_SELECT_ALL,
+ SequencerTrackerElements.CONTEXT_SELECT_COLUMN,
+ SequencerTrackerElements.CONTEXT_SELECT_SUBCOLUMN,
+ )
assert labels[3:7] == ["Copy", "Cut", "Paste", "Delete"]
- assert panel._lbl_context_clear_row in labels
+ assert _tracker_words(SequencerTrackerElements.CONTEXT_CLEAR_ROW)[0] in labels
def test_the_order_action_set_leads_with_the_shapes_a_selection_takes(
self,
@@ -509,12 +542,15 @@ def test_the_order_action_set_leads_with_the_shapes_a_selection_takes(
) -> None:
panel = _order_panel(Gestures())
- panel.add_action_items(panel._surface.target_at(_order_cell(GeneratorName.PULSE1)))
+ panel.add_action_items(panel._surface.target_at(_order_cell(ChannelName.PULSE1)))
labels = [item.label for item in order_recorder.items]
- assert labels[:2] == ["select_all", "select_row"]
+ assert labels[:2] == _order_words(
+ SequencerOrderElements.CONTEXT_SELECT_ALL,
+ SequencerOrderElements.CONTEXT_SELECT_ROW,
+ )
assert labels[2:6] == ["Copy", "Cut", "Paste", "Delete"]
- assert panel._lbl_context_move_end in labels
+ assert _order_words(SequencerOrderElements.CONTEXT_MOVE_END)[0] in labels
class TestMenuItemOrder:
@@ -523,7 +559,7 @@ class TestMenuItemOrder:
def test_the_indices_name_the_items_they_stand_for(self, tracker_recorder: _MenuRecorder) -> None:
panel = _tracker_panel(Gestures())
- panel._surface.add_block_items(panel._surface.target_at(_tracker_cell(GeneratorName.PULSE1)))
+ panel._surface.add_block_items(panel._surface.target_at(_tracker_cell(ChannelName.PULSE1)))
labels = [item.label for item in tracker_recorder.items]
assert labels[COPY_ITEM] == "Copy"
@@ -538,7 +574,7 @@ class TestSelectItems:
def test_the_tracker_items_print_the_keys_they_answer(self, tracker_recorder: _MenuRecorder) -> None:
panel = _tracker_panel(Gestures())
- panel._add_select_items(_tracker_cell(GeneratorName.PULSE1))
+ panel._menu._add_select_items(_tracker_cell(ChannelName.PULSE1))
assert [item.shortcut for item in tracker_recorder.items] == [
"Ctrl+A",
@@ -555,12 +591,12 @@ def test_a_tracker_item_selects_the_column_the_menu_was_raised_on(
panel = _tracker_panel(Gestures())
states = _tracker_selections(monkeypatch, panel)
- panel._add_select_items(_tracker_cell(GeneratorName.TRIANGLE))
+ panel._menu._add_select_items(_tracker_cell(ChannelName.TRIANGLE))
tracker_recorder.items[SELECT_COLUMN_ITEM].callback()
region = states[-1].region
assert region is not None
- assert region.columns == (GeneratorName.TRIANGLE,)
+ assert region.columns == (ChannelName.TRIANGLE,)
assert (region.first_row, region.last_row) == (0, ROW_COUNT - 1)
def test_a_tracker_item_selects_the_whole_frame(
@@ -571,7 +607,7 @@ def test_a_tracker_item_selects_the_whole_frame(
panel = _tracker_panel(Gestures())
states = _tracker_selections(monkeypatch, panel)
- panel._add_select_items(_tracker_cell(GeneratorName.TRIANGLE))
+ panel._menu._add_select_items(_tracker_cell(ChannelName.TRIANGLE))
tracker_recorder.items[SELECT_ALL_ITEM].callback()
region = states[-1].region
@@ -581,7 +617,7 @@ def test_a_tracker_item_selects_the_whole_frame(
def test_the_order_items_print_the_keys_they_answer(self, order_recorder: _MenuRecorder) -> None:
panel = _order_panel(Gestures())
- panel._add_select_items(_order_cell(GeneratorName.PULSE1))
+ panel._menu._add_select_items(_order_cell(ChannelName.PULSE1))
assert [item.shortcut for item in order_recorder.items] == ["Ctrl+A", "Ctrl+Shift+A"]
@@ -593,10 +629,10 @@ def test_an_order_item_selects_the_row_the_menu_was_raised_on(
panel = _order_panel(Gestures())
states = _order_selections(monkeypatch, panel)
- panel._add_select_items(_order_cell(None))
+ panel._menu._add_select_items(_order_cell(None))
order_recorder.items[SELECT_ROW_ITEM].callback()
region = states[-1].region
assert region is not None
- assert region.generators == (None,)
+ assert region.channels == (None,)
assert (region.first_position, region.last_position) == (0, POSITION_COUNT - 1)
diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/test_channels_switch.py b/tests/unit/sampletones_application/ui/panels/sequencer/test_channels_switch.py
index 15503553a..288e5de42 100644
--- a/tests/unit/sampletones_application/ui/panels/sequencer/test_channels_switch.py
+++ b/tests/unit/sampletones_application/ui/panels/sequencer/test_channels_switch.py
@@ -8,7 +8,7 @@
ChannelSwitch,
channel_tooltip,
)
-from sampletones_core.constants.enums import GeneratorName
+from sampletones_core.constants.enums import ChannelName
LABELS = ChannelMenuLabels(
mute="Mute",
@@ -43,8 +43,8 @@ def _switch() -> ChannelSwitch:
"""A switch whose hooks are inert, for reading the items it builds."""
return ChannelSwitch(
labels=LABELS,
- on_mute_toggled=lambda generator: None,
- on_soloed=lambda generator: None,
+ on_mute_toggled=lambda channel: None,
+ on_soloed=lambda channel: None,
on_toggled=lambda: None,
on_muted=lambda: None,
on_unmuted=lambda: None,
@@ -63,7 +63,7 @@ class TestMenuBeforeTheFirstModel:
"""A table whose menu opens before the first mute set arrives reads every channel as audible."""
def test_a_channel_offers_to_mute_and_to_solo(self, menu: _MenuRecorder) -> None:
- _switch().add_menu_items(GeneratorName.TRIANGLE, None)
+ _switch().add_menu_items(ChannelName.TRIANGLE, None)
assert menu.labels == [
LABELS.mute,
@@ -91,19 +91,19 @@ class TestClickRouting:
def test_a_channel_click_carries_its_channel(self, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(channels_module.dpg, "set_value", lambda item, value: None)
monkeypatch.setattr(channels_module, "capture_modifiers", frozenset)
- toggled: List[GeneratorName] = []
+ toggled: List[ChannelName] = []
switch = ChannelSwitch(
labels=LABELS,
on_mute_toggled=toggled.append,
- on_soloed=lambda generator: pytest.fail("a plain click must not solo"),
+ on_soloed=lambda channel: pytest.fail("a plain click must not solo"),
on_toggled=lambda: pytest.fail("a channel click addresses one channel"),
on_muted=lambda: None,
on_unmuted=lambda: None,
)
- switch.click(0, GeneratorName.PULSE1)
+ switch.click(0, ChannelName.PULSE1)
- assert toggled == [GeneratorName.PULSE1]
+ assert toggled == [ChannelName.PULSE1]
def test_the_click_releases_the_selectable(self, monkeypatch: pytest.MonkeyPatch) -> None:
released: Dict[int, bool] = {}
diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/test_columns.py b/tests/unit/sampletones_application/ui/panels/sequencer/test_columns.py
index 64ab59507..91629fd8a 100644
--- a/tests/unit/sampletones_application/ui/panels/sequencer/test_columns.py
+++ b/tests/unit/sampletones_application/ui/panels/sequencer/test_columns.py
@@ -9,13 +9,13 @@
tracker_table_column,
tracker_table_row,
)
-from sampletones_core.constants.enums import GeneratorName
+from sampletones_core.constants.enums import ChannelName
_CHANNEL_COLUMNS = [
- (GeneratorName.PULSE1, 4),
- (GeneratorName.PULSE2, 5),
- (GeneratorName.TRIANGLE, 6),
- (GeneratorName.NOISE, 7),
+ (ChannelName.PULSE1, 4),
+ (ChannelName.PULSE2, 5),
+ (ChannelName.TRIANGLE, 6),
+ (ChannelName.NOISE, 7),
]
_PATTERN_ROWS = [(0, 1), (1, 2), (5, 6), (63, 64)]
@@ -26,20 +26,20 @@ def test_sample_column_directly_precedes_the_divider() -> None:
assert DIVIDER_TABLE_COLUMN == SAMPLE_TABLE_COLUMN + 1
-@pytest.mark.parametrize("generator, expected_column", _CHANNEL_COLUMNS)
-def test_channels_sit_one_slot_past_the_divider(generator: GeneratorName, expected_column: int) -> None:
- assert tracker_table_column(generator) == expected_column
+@pytest.mark.parametrize("channel, expected_column", _CHANNEL_COLUMNS)
+def test_channels_sit_one_slot_past_the_divider(channel: ChannelName, expected_column: int) -> None:
+ assert tracker_table_column(channel) == expected_column
def test_no_logical_column_lands_on_the_divider() -> None:
- mapped = {tracker_table_column(None)} | {tracker_table_column(generator) for generator in GeneratorName.items()}
+ mapped = {tracker_table_column(None)} | {tracker_table_column(channel) for channel in ChannelName.items()}
assert DIVIDER_TABLE_COLUMN not in mapped
- assert len(mapped) == len(GeneratorName.items()) + 1
+ assert len(mapped) == len(ChannelName.items()) + 1
def test_every_mapped_column_lies_within_the_table() -> None:
- mapped = {tracker_table_column(None)} | {tracker_table_column(generator) for generator in GeneratorName.items()}
+ mapped = {tracker_table_column(None)} | {tracker_table_column(channel) for channel in ChannelName.items()}
assert DIVIDER_TABLE_COLUMN < TRACKER_TABLE_COLUMNS
assert max(mapped) < TRACKER_TABLE_COLUMNS
diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/test_history_role_color.py b/tests/unit/sampletones_application/ui/panels/sequencer/test_history_role_color.py
new file mode 100644
index 000000000..8e1666883
--- /dev/null
+++ b/tests/unit/sampletones_application/ui/panels/sequencer/test_history_role_color.py
@@ -0,0 +1,74 @@
+import pytest
+
+from sampletones_application.layout.config import LayoutConfig
+from sampletones_application.layout.loader import load_layout_config
+from sampletones_application.paths import (
+ BEHAVIOR_DIRECTORY,
+ LAYOUT_DIRECTORY,
+ PALETTES_DIRECTORY,
+)
+from sampletones_application.ui.panels.sequencer.history import GUISequencerHistoryPanel
+from sampletones_application.utils.palette.catalog import PaletteCatalog
+from sampletones_application.utils.palette.source import PaletteSource
+from sampletones_application.view_model.shared.history import HistoryDetailRole
+
+
+@pytest.fixture
+def layout_config() -> LayoutConfig:
+ source = PaletteSource(PaletteCatalog.load(PALETTES_DIRECTORY).default)
+ return load_layout_config(LAYOUT_DIRECTORY, BEHAVIOR_DIRECTORY, source)
+
+
+@pytest.fixture
+def panel(layout_config: LayoutConfig) -> GUISequencerHistoryPanel:
+ """Builds a panel without its DearPyGui-dependent constructor.
+
+ A role's color is read from the layout alone, so a running GUI context is unnecessary here.
+ """
+ instance = GUISequencerHistoryPanel.__new__(GUISequencerHistoryPanel)
+ instance._layout = layout_config.tabs.sequencer
+ instance._feature_colors = layout_config.general.colors.features
+ return instance
+
+
+class TestWhatColorAVoiceRoleWears:
+ """A history line names its voice in the color of the kind that voice is."""
+
+ def test_a_sample_wears_the_sample_color(
+ self,
+ panel: GUISequencerHistoryPanel,
+ layout_config: LayoutConfig,
+ ) -> None:
+ text = layout_config.tabs.sequencer.colors.text
+
+ assert panel._role_color(HistoryDetailRole.SAMPLE) is text.sample
+
+ def test_an_instrument_wears_the_instrument_color(
+ self,
+ panel: GUISequencerHistoryPanel,
+ layout_config: LayoutConfig,
+ ) -> None:
+ text = layout_config.tabs.sequencer.colors.text
+
+ assert panel._role_color(HistoryDetailRole.INSTRUMENT) is text.instrument
+
+ def test_a_voice_of_no_stated_kind_wears_the_slot_color(
+ self,
+ panel: GUISequencerHistoryPanel,
+ layout_config: LayoutConfig,
+ ) -> None:
+ """The tracker's voice slot and a voice the pool dropped read as one thing."""
+ text = layout_config.tabs.sequencer.colors.text
+
+ assert panel._role_color(HistoryDetailRole.VOICE) is text.voice
+
+ def test_the_two_kinds_are_told_apart(self, panel: GUISequencerHistoryPanel) -> None:
+ sample = panel._role_color(HistoryDetailRole.SAMPLE)
+ instrument = panel._role_color(HistoryDetailRole.INSTRUMENT)
+
+ assert sample.rgba != instrument.rgba
+
+ def test_every_role_answers_with_a_color(self, panel: GUISequencerHistoryPanel) -> None:
+ """The panel paints whatever the logic tags, so each role states what it wears."""
+ for role in HistoryDetailRole:
+ assert panel._role_color(role) is not None
diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/test_order_channels.py b/tests/unit/sampletones_application/ui/panels/sequencer/test_order_channels.py
index 47dbb8ee1..9938a33bf 100644
--- a/tests/unit/sampletones_application/ui/panels/sequencer/test_order_channels.py
+++ b/tests/unit/sampletones_application/ui/panels/sequencer/test_order_channels.py
@@ -5,12 +5,14 @@
import pytest
from sampletones_application.categories.manager import LanguageManager
-from sampletones_application.layout.tabs.sequencer.colors.channel import ChannelColors
+from sampletones_application.layout.general.colors.channel import ChannelColors
from sampletones_application.paths import LANG_EN
from sampletones_application.ui.elements.table.cells import EditableCells
from sampletones_application.ui.panels.sequencer import channels as channels_module
-from sampletones_application.ui.panels.sequencer import order as order_module
-from sampletones_application.ui.panels.sequencer.order import GUISequencerOrderPanel
+from sampletones_application.ui.panels.sequencer.order import menu as menu_module
+from sampletones_application.ui.panels.sequencer.order import panel as order_module
+from sampletones_application.ui.panels.sequencer.order.menu import OrderMenu
+from sampletones_application.ui.panels.sequencer.order.panel import GUISequencerOrderPanel
from sampletones_application.utils.gui.keyboard.modifiers import (
CTRL,
NO_MODIFIERS,
@@ -20,9 +22,10 @@
from sampletones_application.view_model.sequencer.channels import (
SequencerChannelsViewModel,
)
-from sampletones_core.constants.enums import GeneratorName
+from sampletones_core.constants.enums import ChannelName
from sampletones_shared.types.application import ColorRGBA, Sender
from sampletones_shared.types.callback import VoidCallback
+from tests.suite.shortcuts import shipped_source
LABEL_WIDGET_ID: Sender = 9100
"""A stand-in for the row-label id DearPyGui passes as the callback's sender."""
@@ -54,27 +57,27 @@
LABEL_MUTE_ALL = "Mute all channels"
LABEL_UNMUTE_ALL = "Unmute all channels"
-ROW_LABELS: Dict[Optional[GeneratorName], str] = {
+ROW_LABELS: Dict[Optional[ChannelName], str] = {
None: "Master",
- GeneratorName.PULSE1: "Pulse 1",
- GeneratorName.PULSE2: "Pulse 2",
- GeneratorName.TRIANGLE: "Triangle",
- GeneratorName.NOISE: "Noise",
+ ChannelName.PULSE1: "Pulse 1",
+ ChannelName.PULSE2: "Pulse 2",
+ ChannelName.TRIANGLE: "Triangle",
+ ChannelName.NOISE: "Noise",
}
-CHANNEL_TABLE_ROWS: Dict[GeneratorName, int] = {
- GeneratorName.PULSE1: 2,
- GeneratorName.PULSE2: 3,
- GeneratorName.TRIANGLE: 4,
- GeneratorName.NOISE: 5,
+CHANNEL_TABLE_ROWS: Dict[ChannelName, int] = {
+ ChannelName.PULSE1: 2,
+ ChannelName.PULSE2: 3,
+ ChannelName.TRIANGLE: 4,
+ ChannelName.NOISE: 5,
}
"""Each channel's table row: the master row, the divider beneath it, then the four channels."""
-LABEL_WIDGETS: Dict[Optional[GeneratorName], Sender] = {row: 300 + index for index, row in enumerate(ROW_LABELS)}
+LABEL_WIDGETS: Dict[Optional[ChannelName], Sender] = {row: 300 + index for index, row in enumerate(ROW_LABELS)}
-def _entry_widget(generator: GeneratorName, position: int) -> int:
- return 2000 + 100 * GeneratorName.items().index(generator) + position
+def _entry_widget(channel: ChannelName, position: int) -> int:
+ return 2000 + 100 * ChannelName.items().index(channel) + position
class _DearPyGuiRecorder:
@@ -131,10 +134,10 @@ def click(self, label: str) -> None:
self.callbacks[label]()
-def _panel(muted: FrozenSet[GeneratorName]) -> GUISequencerOrderPanel:
+def _panel(muted: FrozenSet[ChannelName]) -> GUISequencerOrderPanel:
"""Builds a panel around the state the channel cues read, with no DearPyGui context.
- The cues touch the layout colours, the theme ids, the row labels, and the entry registry, so
+ The cues touch the layout colors, the theme ids, the row labels, and the entry registry, so
those are wired directly and the rest of the panel is left out. The switch behind the label is
built the way the panel builds it, from the real language file, so the item labels under test
are the ones a user reads.
@@ -142,7 +145,6 @@ def _panel(muted: FrozenSet[GeneratorName]) -> GUISequencerOrderPanel:
panel = GUISequencerOrderPanel.__new__(GUISequencerOrderPanel)
panel._layout = SimpleNamespace(
colors=SimpleNamespace(
- channels=CHANNEL_COLORS,
muted=SimpleNamespace(background=LiteralColor(MUTED_BACKGROUND)),
),
tracker=SimpleNamespace(
@@ -150,6 +152,7 @@ def _panel(muted: FrozenSet[GeneratorName]) -> GUISequencerOrderPanel:
muted_text_fraction=MUTED_TEXT_FRACTION,
),
)
+ panel._channel_colors = CHANNEL_COLORS
panel._current_channels = SequencerChannelsViewModel(muted=muted)
panel._position_count = POSITION_COUNT
panel._row_labels = dict(ROW_LABELS)
@@ -159,11 +162,17 @@ def _panel(muted: FrozenSet[GeneratorName]) -> GUISequencerOrderPanel:
panel._entry_theme = ENTRY_THEME
panel._muted_entry_theme = MUTED_ENTRY_THEME
panel._order = EditableCells()
- for generator in GeneratorName.items():
+ for channel in ChannelName.items():
for position in range(POSITION_COUNT):
- panel._order.register((generator, position), _entry_widget(generator, position))
-
- panel._create_channel_switch(LanguageManager(LANG_EN))
+ panel._order.register((channel, position), _entry_widget(channel, position))
+
+ language_manager = LanguageManager(LANG_EN)
+ panel._create_channel_switch(language_manager)
+ panel._menu = OrderMenu(
+ panel,
+ language_manager=language_manager,
+ shortcut_source=shipped_source(),
+ )
return panel
@@ -180,16 +189,16 @@ def recorder(monkeypatch: pytest.MonkeyPatch) -> _DearPyGuiRecorder:
@pytest.fixture
def menu(monkeypatch: pytest.MonkeyPatch) -> _MenuRecorder:
instance = _MenuRecorder()
- monkeypatch.setattr(order_module.dpg, "add_menu_item", instance.add_menu_item)
- monkeypatch.setattr(order_module.dpg, "add_text", instance.add_text)
- monkeypatch.setattr(order_module.dpg, "add_separator", instance.add_separator)
- monkeypatch.setattr(order_module.FontRegistry, "bind_to_item", lambda item, font: None)
+ monkeypatch.setattr(menu_module.dpg, "add_menu_item", instance.add_menu_item)
+ monkeypatch.setattr(menu_module.dpg, "add_text", instance.add_text)
+ monkeypatch.setattr(menu_module.dpg, "add_separator", instance.add_separator)
+ monkeypatch.setattr(menu_module.FontRegistry, "bind_to_item", lambda item, font: None)
@contextlib.contextmanager
def _popup() -> Iterator[None]:
yield
- monkeypatch.setattr(order_module, "context_menu", _popup)
+ monkeypatch.setattr(menu_module, "context_menu", _popup)
return instance
@@ -197,7 +206,7 @@ def _hold(monkeypatch: pytest.MonkeyPatch, modifiers: ModifierSet) -> None:
monkeypatch.setattr(channels_module, "capture_modifiers", lambda: modifiers)
-def _right_click(panel: GUISequencerOrderPanel, row: Optional[GeneratorName]) -> None:
+def _right_click(panel: GUISequencerOrderPanel, row: Optional[ChannelName]) -> None:
panel._on_label_right_clicked(
LABEL_WIDGET_ID,
(order_module.dpg.mvMouseButton_Right, LABEL_WIDGETS[row]),
@@ -212,12 +221,12 @@ def test_plain_click_toggles_the_clicked_channel(
) -> None:
panel = _panel(frozenset())
_hold(monkeypatch, NO_MODIFIERS)
- toggled: List[GeneratorName] = []
+ toggled: List[ChannelName] = []
panel.on_channel_mute_toggled = toggled.append
- panel._on_label_clicked(LABEL_WIDGET_ID, True, GeneratorName.TRIANGLE)
+ panel._on_label_clicked(LABEL_WIDGET_ID, True, ChannelName.TRIANGLE)
- assert toggled == [GeneratorName.TRIANGLE]
+ assert toggled == [ChannelName.TRIANGLE]
def test_ctrl_click_solos_the_clicked_channel(
self,
@@ -226,13 +235,13 @@ def test_ctrl_click_solos_the_clicked_channel(
) -> None:
panel = _panel(frozenset())
_hold(monkeypatch, CTRL)
- soloed: List[GeneratorName] = []
+ soloed: List[ChannelName] = []
panel.on_channel_soloed = soloed.append
panel.on_channel_mute_toggled = lambda _: pytest.fail("Ctrl+click must not toggle")
- panel._on_label_clicked(LABEL_WIDGET_ID, True, GeneratorName.NOISE)
+ panel._on_label_clicked(LABEL_WIDGET_ID, True, ChannelName.NOISE)
- assert soloed == [GeneratorName.NOISE]
+ assert soloed == [ChannelName.NOISE]
def test_master_label_switches_every_channel(
self,
@@ -258,7 +267,7 @@ def test_every_label_click_releases_the_selectable(
self,
recorder: _DearPyGuiRecorder,
monkeypatch: pytest.MonkeyPatch,
- row: Optional[GeneratorName],
+ row: Optional[ChannelName],
) -> None:
panel = _panel(frozenset())
_hold(monkeypatch, NO_MODIFIERS)
@@ -276,7 +285,7 @@ def test_audible_channel_keeps_its_identity_tint(self, recorder: _DearPyGuiRecor
panel._apply_channel_cues()
- assert recorder.row_tints[CHANNEL_TABLE_ROWS[GeneratorName.PULSE1]] == (
+ assert recorder.row_tints[CHANNEL_TABLE_ROWS[ChannelName.PULSE1]] == (
240,
146,
86,
@@ -284,57 +293,55 @@ def test_audible_channel_keeps_its_identity_tint(self, recorder: _DearPyGuiRecor
)
def test_muted_channel_takes_the_neutral_wash(self, recorder: _DearPyGuiRecorder) -> None:
- panel = _panel(frozenset({GeneratorName.PULSE1}))
+ panel = _panel(frozenset({ChannelName.PULSE1}))
panel._apply_channel_cues()
- assert recorder.row_tints[CHANNEL_TABLE_ROWS[GeneratorName.PULSE1]] == MUTED_BACKGROUND
+ assert recorder.row_tints[CHANNEL_TABLE_ROWS[ChannelName.PULSE1]] == MUTED_BACKGROUND
def test_the_other_channels_keep_their_tint_while_one_is_muted(self, recorder: _DearPyGuiRecorder) -> None:
- panel = _panel(frozenset({GeneratorName.TRIANGLE}))
+ panel = _panel(frozenset({ChannelName.TRIANGLE}))
panel._apply_channel_cues()
- washed = {
- generator for generator, row in CHANNEL_TABLE_ROWS.items() if recorder.row_tints[row] == MUTED_BACKGROUND
- }
- assert washed == {GeneratorName.TRIANGLE}
+ washed = {channel for channel, row in CHANNEL_TABLE_ROWS.items() if recorder.row_tints[row] == MUTED_BACKGROUND}
+ assert washed == {ChannelName.TRIANGLE}
def test_the_master_row_carries_no_channel_wash(self, recorder: _DearPyGuiRecorder) -> None:
"""The master row stands for every channel, so it takes its own shade instead of a tint."""
- panel = _panel(frozenset(GeneratorName.items()))
+ panel = _panel(frozenset(ChannelName.items()))
panel._apply_channel_cues()
assert set(recorder.row_tints) == set(CHANNEL_TABLE_ROWS.values())
def test_the_wash_matches_the_shade_the_tracker_column_takes(self, recorder: _DearPyGuiRecorder) -> None:
- """Both tables read the same colour, so a silenced channel looks the same in each."""
- panel = _panel(frozenset({GeneratorName.NOISE}))
+ """Both tables read the same color, so a silenced channel looks the same in each."""
+ panel = _panel(frozenset({ChannelName.NOISE}))
- assert panel._channel_row_tint(GeneratorName.NOISE) == MUTED_BACKGROUND
+ assert panel._channel_row_tint(ChannelName.NOISE) == MUTED_BACKGROUND
class TestRowLabelShade:
def test_muted_channel_label_takes_the_muted_shade(self, recorder: _DearPyGuiRecorder) -> None:
- panel = _panel(frozenset({GeneratorName.PULSE2}))
+ panel = _panel(frozenset({ChannelName.PULSE2}))
panel._apply_channel_cues()
- assert recorder.bound_themes[LABEL_WIDGETS[GeneratorName.PULSE2]] == MUTED_LABEL_THEME
+ assert recorder.bound_themes[LABEL_WIDGETS[ChannelName.PULSE2]] == MUTED_LABEL_THEME
def test_audible_channel_label_keeps_the_plain_shade(self, recorder: _DearPyGuiRecorder) -> None:
- panel = _panel(frozenset({GeneratorName.PULSE2}))
+ panel = _panel(frozenset({ChannelName.PULSE2}))
panel._apply_channel_cues()
- assert recorder.bound_themes[LABEL_WIDGETS[GeneratorName.NOISE]] == LABEL_THEME
+ assert recorder.bound_themes[LABEL_WIDGETS[ChannelName.NOISE]] == LABEL_THEME
def test_master_label_keeps_the_plain_shade_with_every_channel_muted(
self,
recorder: _DearPyGuiRecorder,
) -> None:
- panel = _panel(frozenset(GeneratorName.items()))
+ panel = _panel(frozenset(ChannelName.items()))
panel._apply_channel_cues()
@@ -343,33 +350,33 @@ def test_master_label_keeps_the_plain_shade_with_every_channel_muted(
class TestEntryTextShade:
def test_muted_channel_entries_take_the_dimmed_theme(self, recorder: _DearPyGuiRecorder) -> None:
- panel = _panel(frozenset({GeneratorName.TRIANGLE}))
+ panel = _panel(frozenset({ChannelName.TRIANGLE}))
panel._apply_channel_cues()
bound = {
- recorder.bound_themes[_entry_widget(GeneratorName.TRIANGLE, position)] for position in range(POSITION_COUNT)
+ recorder.bound_themes[_entry_widget(ChannelName.TRIANGLE, position)] for position in range(POSITION_COUNT)
}
assert bound == {MUTED_ENTRY_THEME}
def test_audible_channel_entries_keep_the_full_theme(self, recorder: _DearPyGuiRecorder) -> None:
- panel = _panel(frozenset({GeneratorName.TRIANGLE}))
+ panel = _panel(frozenset({ChannelName.TRIANGLE}))
panel._apply_channel_cues()
bound = {
- recorder.bound_themes[_entry_widget(GeneratorName.PULSE1, position)] for position in range(POSITION_COUNT)
+ recorder.bound_themes[_entry_widget(ChannelName.PULSE1, position)] for position in range(POSITION_COUNT)
}
assert bound == {ENTRY_THEME}
def test_unmuting_restores_the_full_theme(self, recorder: _DearPyGuiRecorder) -> None:
- panel = _panel(frozenset({GeneratorName.NOISE}))
+ panel = _panel(frozenset({ChannelName.NOISE}))
panel._apply_channel_cues()
panel.update_channels(SequencerChannelsViewModel(muted=frozenset()))
bound = {
- recorder.bound_themes[_entry_widget(GeneratorName.NOISE, position)] for position in range(POSITION_COUNT)
+ recorder.bound_themes[_entry_widget(ChannelName.NOISE, position)] for position in range(POSITION_COUNT)
}
assert bound == {ENTRY_THEME}
@@ -378,9 +385,9 @@ class TestPushedModel:
def test_the_pushed_model_is_kept_for_the_next_rebuild(self, recorder: _DearPyGuiRecorder) -> None:
panel = _panel(frozenset())
- panel.update_channels(SequencerChannelsViewModel(muted=frozenset({GeneratorName.PULSE1})))
+ panel.update_channels(SequencerChannelsViewModel(muted=frozenset({ChannelName.PULSE1})))
- assert panel._is_muted(GeneratorName.PULSE1)
+ assert panel._is_muted(ChannelName.PULSE1)
def test_a_panel_awaiting_its_first_model_reports_every_channel_audible(
self,
@@ -389,16 +396,16 @@ def test_a_panel_awaiting_its_first_model_reports_every_channel_audible(
panel = _panel(frozenset())
panel._current_channels = None
- assert not any(panel._is_muted(generator) for generator in GeneratorName.items())
+ assert not any(panel._is_muted(channel) for channel in ChannelName.items())
class TestRowLabelRightClickRouting:
def test_a_right_click_opens_the_menu_for_the_clicked_row(self, menu: _MenuRecorder) -> None:
panel = _panel(frozenset())
- _right_click(panel, GeneratorName.TRIANGLE)
+ _right_click(panel, ChannelName.TRIANGLE)
- assert menu.titles == [ROW_LABELS[GeneratorName.TRIANGLE]]
+ assert menu.titles == [ROW_LABELS[ChannelName.TRIANGLE]]
def test_the_master_label_opens_the_whole_mix_menu(self, menu: _MenuRecorder) -> None:
panel = _panel(frozenset())
@@ -413,7 +420,7 @@ def test_a_left_click_opens_no_menu(self, menu: _MenuRecorder) -> None:
panel._on_label_right_clicked(
LABEL_WIDGET_ID,
- (order_module.dpg.mvMouseButton_Left, LABEL_WIDGETS[GeneratorName.NOISE]),
+ (order_module.dpg.mvMouseButton_Left, LABEL_WIDGETS[ChannelName.NOISE]),
)
assert menu.titles == []
@@ -441,14 +448,14 @@ class TestRowLabelMenuItems:
def test_a_channel_menu_carries_its_own_gestures_and_the_whole_mix(self, menu: _MenuRecorder) -> None:
panel = _panel(frozenset())
- _right_click(panel, GeneratorName.PULSE1)
+ _right_click(panel, ChannelName.PULSE1)
assert menu.labels == [LABEL_MUTE, LABEL_SOLO, LABEL_MUTE_ALL, LABEL_UNMUTE_ALL]
def test_the_items_name_the_change_they_make(self, menu: _MenuRecorder) -> None:
- panel = _panel(frozenset(GeneratorName.items()) - {GeneratorName.PULSE1})
+ panel = _panel(frozenset(ChannelName.items()) - {ChannelName.PULSE1})
- _right_click(panel, GeneratorName.PULSE1)
+ _right_click(panel, ChannelName.PULSE1)
assert menu.labels == [
LABEL_MUTE,
@@ -458,9 +465,9 @@ def test_the_items_name_the_change_they_make(self, menu: _MenuRecorder) -> None:
]
def test_muting_everything_is_withheld_in_full_silence(self, menu: _MenuRecorder) -> None:
- panel = _panel(frozenset(GeneratorName.items()))
+ panel = _panel(frozenset(ChannelName.items()))
- _right_click(panel, GeneratorName.NOISE)
+ _right_click(panel, ChannelName.NOISE)
assert not menu.is_enabled(LABEL_MUTE_ALL)
assert menu.is_enabled(LABEL_UNMUTE_ALL)
@@ -468,33 +475,33 @@ def test_muting_everything_is_withheld_in_full_silence(self, menu: _MenuRecorder
def test_restoring_everything_is_withheld_in_the_full_mix(self, menu: _MenuRecorder) -> None:
panel = _panel(frozenset())
- _right_click(panel, GeneratorName.NOISE)
+ _right_click(panel, ChannelName.NOISE)
assert menu.is_enabled(LABEL_MUTE_ALL)
assert not menu.is_enabled(LABEL_UNMUTE_ALL)
def test_the_mute_item_switches_the_clicked_channel(self, menu: _MenuRecorder) -> None:
panel = _panel(frozenset())
- toggled: List[GeneratorName] = []
+ toggled: List[ChannelName] = []
panel.on_channel_mute_toggled = toggled.append
- _right_click(panel, GeneratorName.PULSE2)
+ _right_click(panel, ChannelName.PULSE2)
menu.click(LABEL_MUTE)
- assert toggled == [GeneratorName.PULSE2]
+ assert toggled == [ChannelName.PULSE2]
def test_the_solo_item_solos_the_clicked_channel(self, menu: _MenuRecorder) -> None:
panel = _panel(frozenset())
- soloed: List[GeneratorName] = []
+ soloed: List[ChannelName] = []
panel.on_channel_soloed = soloed.append
- _right_click(panel, GeneratorName.TRIANGLE)
+ _right_click(panel, ChannelName.TRIANGLE)
menu.click(LABEL_SOLO)
- assert soloed == [GeneratorName.TRIANGLE]
+ assert soloed == [ChannelName.TRIANGLE]
def test_the_whole_mix_items_reach_their_own_hooks(self, menu: _MenuRecorder) -> None:
- panel = _panel(frozenset({GeneratorName.NOISE}))
+ panel = _panel(frozenset({ChannelName.NOISE}))
calls: List[str] = []
panel.on_channels_muted = lambda: calls.append("muted")
panel.on_channels_unmuted = lambda: calls.append("unmuted")
diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/test_order_keys.py b/tests/unit/sampletones_application/ui/panels/sequencer/test_order_keys.py
index 204bfd083..1009cc7c6 100644
--- a/tests/unit/sampletones_application/ui/panels/sequencer/test_order_keys.py
+++ b/tests/unit/sampletones_application/ui/panels/sequencer/test_order_keys.py
@@ -7,10 +7,10 @@
OrderCursor,
OrderInputState,
)
-from sampletones_application.ui.panels.sequencer.order import GUISequencerOrderPanel
+from sampletones_application.ui.panels.sequencer.order.panel import GUISequencerOrderPanel
from sampletones_application.utils.gui.keyboard.combination import KeyCombination
from sampletones_application.utils.gui.keyboard.event import KeyEvent
-from sampletones_core.constants.enums import GeneratorName
+from sampletones_core.constants.enums import ChannelName
from tests.suite.shortcuts import shipped_source
POSITION_COUNT = 4
@@ -38,7 +38,7 @@ class OrderPanelFixture:
def order(monkeypatch: pytest.MonkeyPatch) -> OrderPanelFixture:
panel = GUISequencerOrderPanel.__new__(GUISequencerOrderPanel)
panel._shortcuts = shipped_source()
- panel._input_state = OrderInputState(cursor=OrderCursor(GeneratorName.PULSE1, CURSOR_POSITION))
+ panel._input_state = OrderInputState(cursor=OrderCursor(ChannelName.PULSE1, CURSOR_POSITION))
panel._position_count = POSITION_COUNT
panel._current_position = CURSOR_POSITION
panel._buttons = None
@@ -110,7 +110,7 @@ def test_the_move_to_end_key_moves_the_frame_last(self, order: OrderPanelFixture
def test_a_move_with_nowhere_to_go_still_consumes_the_key(self, order: OrderPanelFixture) -> None:
"""A boundary keeps the press, so a repeated move stays out of the global shortcuts."""
- order.panel._input_state = OrderInputState(cursor=OrderCursor(GeneratorName.PULSE1, 0))
+ order.panel._input_state = OrderInputState(cursor=OrderCursor(ChannelName.PULSE1, 0))
assert order.panel._on_key_pressed(_press("Alt+Left")) is True
assert order.moved == []
@@ -119,15 +119,15 @@ def test_a_move_with_nowhere_to_go_still_consumes_the_key(self, order: OrderPane
class TestCursorMoves:
def test_the_next_position_key_moves_the_cursor_one_column_on(self, order: OrderPanelFixture) -> None:
assert order.panel._on_key_pressed(_press("Right")) is True
- assert order.states[-1].cursor == OrderCursor(GeneratorName.PULSE1, CURSOR_POSITION + 1)
+ assert order.states[-1].cursor == OrderCursor(ChannelName.PULSE1, CURSOR_POSITION + 1)
def test_the_enter_alias_moves_the_cursor_the_same_way(self, order: OrderPanelFixture) -> None:
assert order.panel._on_key_pressed(_press("Enter")) is True
- assert order.states[-1].cursor == OrderCursor(GeneratorName.PULSE1, CURSOR_POSITION + 1)
+ assert order.states[-1].cursor == OrderCursor(ChannelName.PULSE1, CURSOR_POSITION + 1)
def test_the_last_position_key_jumps_to_the_final_column(self, order: OrderPanelFixture) -> None:
assert order.panel._on_key_pressed(_press("End")) is True
- assert order.states[-1].cursor == OrderCursor(GeneratorName.PULSE1, POSITION_COUNT - 1)
+ assert order.states[-1].cursor == OrderCursor(ChannelName.PULSE1, POSITION_COUNT - 1)
class TestCellEntry:
diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/test_order_remove.py b/tests/unit/sampletones_application/ui/panels/sequencer/test_order_remove.py
index 8308e931f..8ed675ccb 100644
--- a/tests/unit/sampletones_application/ui/panels/sequencer/test_order_remove.py
+++ b/tests/unit/sampletones_application/ui/panels/sequencer/test_order_remove.py
@@ -5,8 +5,8 @@
OrderCursor,
OrderInputState,
)
-from sampletones_application.ui.panels.sequencer.order import GUISequencerOrderPanel
-from sampletones_core.constants.enums import GeneratorName
+from sampletones_application.ui.panels.sequencer.order.panel import GUISequencerOrderPanel
+from sampletones_core.constants.enums import ChannelName
POSITION_COUNT = 4
@@ -55,7 +55,7 @@ class TestRemovableFrame:
"""The frame ``[-]`` acts on: the cursor's frame, else the followed tracker frame."""
def test_cursor_frame_wins(self) -> None:
- fixture = _panel(cursor=OrderCursor(GeneratorName.PULSE1, 2), current_position=0)
+ fixture = _panel(cursor=OrderCursor(ChannelName.PULSE1, 2), current_position=0)
assert fixture.panel._get_removable_position() == 2
@@ -104,7 +104,7 @@ def test_disabled_on_an_empty_order(self) -> None:
class TestRemoveClick:
def test_click_removes_the_selected_frame(self) -> None:
- fixture = _panel(cursor=OrderCursor(GeneratorName.NOISE, 3))
+ fixture = _panel(cursor=OrderCursor(ChannelName.NOISE, 3))
fixture.panel._on_remove_clicked()
assert fixture.removed == [3]
diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/test_panel_escape.py b/tests/unit/sampletones_application/ui/panels/sequencer/test_panel_escape.py
index 0557c7b7a..5ce3bc19b 100644
--- a/tests/unit/sampletones_application/ui/panels/sequencer/test_panel_escape.py
+++ b/tests/unit/sampletones_application/ui/panels/sequencer/test_panel_escape.py
@@ -8,8 +8,8 @@
OrderInputState,
)
from sampletones_application.ui.panels.sequencer.input.tracker import TrackerCursor, TrackerInputState
-from sampletones_application.ui.panels.sequencer.order import GUISequencerOrderPanel
-from sampletones_application.ui.panels.sequencer.tracker import GUISequencerTrackerPanel
+from sampletones_application.ui.panels.sequencer.order.panel import GUISequencerOrderPanel
+from sampletones_application.ui.panels.sequencer.tracker.panel import GUISequencerTrackerPanel
from sampletones_application.utils.gui.keyboard import KeyEvent
from sampletones_application.utils.gui.keyboard.modifiers import NO_MODIFIERS
from sampletones_application.view_model.sequencer.subcolumn import SubColumn
@@ -26,14 +26,14 @@ class TestTrackerEscapeYieldsToGlobalStop:
def test_escape_yields_when_no_pending_edit(self) -> None:
panel = GUISequencerTrackerPanel.__new__(GUISequencerTrackerPanel)
panel._shortcuts = shipped_source()
- panel._input_state = TrackerInputState(cursor=TrackerCursor(0, None, SubColumn.INSTRUMENT), pending="")
+ panel._input_state = TrackerInputState(cursor=TrackerCursor(0, None, SubColumn.VOICE), pending="")
assert panel._on_key_pressed(_escape()) is False
def test_escape_cancels_a_pending_edit_and_consumes(self, monkeypatch: pytest.MonkeyPatch) -> None:
panel = GUISequencerTrackerPanel.__new__(GUISequencerTrackerPanel)
panel._shortcuts = shipped_source()
- panel._input_state = TrackerInputState(cursor=TrackerCursor(0, None, SubColumn.INSTRUMENT), pending="3")
+ panel._input_state = TrackerInputState(cursor=TrackerCursor(0, None, SubColumn.VOICE), pending="3")
applied: List[TrackerInputState] = []
monkeypatch.setattr(panel, "_apply_state", applied.append)
@@ -46,8 +46,8 @@ def test_escape_drops_a_selection_and_consumes(self, monkeypatch: pytest.MonkeyP
panel._shortcuts = shipped_source()
panel._current_row_count = 64
panel._input_state = TrackerInputState(
- cursor=TrackerCursor(4, None, SubColumn.INSTRUMENT),
- anchor=TrackerCursor(2, None, SubColumn.INSTRUMENT),
+ cursor=TrackerCursor(4, None, SubColumn.VOICE),
+ anchor=TrackerCursor(2, None, SubColumn.VOICE),
)
applied: List[TrackerInputState] = []
monkeypatch.setattr(panel, "_apply_state", applied.append)
diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/test_panel_tab_gate.py b/tests/unit/sampletones_application/ui/panels/sequencer/test_panel_tab_gate.py
index 08b1610a8..f3119af22 100644
--- a/tests/unit/sampletones_application/ui/panels/sequencer/test_panel_tab_gate.py
+++ b/tests/unit/sampletones_application/ui/panels/sequencer/test_panel_tab_gate.py
@@ -8,9 +8,9 @@
OrderInputState,
)
from sampletones_application.ui.panels.sequencer.input.tracker import TrackerCursor, TrackerInputState
-from sampletones_application.ui.panels.sequencer.order import GUISequencerOrderPanel
-from sampletones_application.ui.panels.sequencer.samples import GUISequencerSamplesPanel
-from sampletones_application.ui.panels.sequencer.tracker import GUISequencerTrackerPanel
+from sampletones_application.ui.panels.sequencer.order.panel import GUISequencerOrderPanel
+from sampletones_application.ui.panels.sequencer.tracker.panel import GUISequencerTrackerPanel
+from sampletones_application.ui.panels.sequencer.voices.panel import GUISequencerVoicesPanel
from sampletones_application.utils.gui.keyboard import ActivePredicate, KeyRouter, focus
from sampletones_application.view_model.sequencer.subcolumn import SubColumn
from tests.suite.base import BaseTestSuite
@@ -19,7 +19,7 @@
SequencerPanel = Union[
GUISequencerTrackerPanel,
GUISequencerOrderPanel,
- GUISequencerSamplesPanel,
+ GUISequencerVoicesPanel,
]
SELECTED_ID = "bass-id"
@@ -36,7 +36,7 @@ def _tracker(tab_active: ActivePredicate) -> GUISequencerTrackerPanel:
panel = GUISequencerTrackerPanel.__new__(GUISequencerTrackerPanel)
panel._router = KeyRouter()
panel._tab_active = tab_active
- panel._input_state = TrackerInputState(cursor=TrackerCursor(0, None, SubColumn.INSTRUMENT))
+ panel._input_state = TrackerInputState(cursor=TrackerCursor(0, None, SubColumn.VOICE))
return panel
@@ -49,20 +49,20 @@ def _order(tab_active: ActivePredicate) -> GUISequencerOrderPanel:
return panel
-def _samples(tab_active: ActivePredicate) -> GUISequencerSamplesPanel:
+def _samples(tab_active: ActivePredicate) -> GUISequencerVoicesPanel:
"""A samples panel holding a selection, which is what it keeps across a move to another tab."""
- panel = GUISequencerSamplesPanel.__new__(GUISequencerSamplesPanel)
+ panel = GUISequencerVoicesPanel.__new__(GUISequencerVoicesPanel)
panel._router = KeyRouter()
panel._tab_active = tab_active
- panel._selected_sample_id = SELECTED_ID
- panel._editing_sample_id = None
+ panel._selected_voice_id = SELECTED_ID
+ panel._editing_voice_id = None
return panel
-def _renaming_samples(tab_active: ActivePredicate) -> GUISequencerSamplesPanel:
+def _renaming_samples(tab_active: ActivePredicate) -> GUISequencerVoicesPanel:
"""A samples panel mid-rename, the one state that keeps the keyboard on its own tab."""
panel = _samples(tab_active)
- panel._editing_sample_id = SELECTED_ID
+ panel._editing_voice_id = SELECTED_ID
return panel
diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/test_rows.py b/tests/unit/sampletones_application/ui/panels/sequencer/test_rows.py
index 47cb8e2b8..6da0bbade 100644
--- a/tests/unit/sampletones_application/ui/panels/sequencer/test_rows.py
+++ b/tests/unit/sampletones_application/ui/panels/sequencer/test_rows.py
@@ -32,7 +32,7 @@ def _settings(
first_highlight: int = BEAT_ROWS,
second_highlight: int = BAR_ROWS,
) -> SequencerSettingsViewModel:
- """The module settings the row tinting reads, carrying the metre under test."""
+ """The module settings the row tinting reads, carrying the meter under test."""
return SequencerSettingsViewModel(
nes_frequency=60,
tempo=150,
@@ -100,7 +100,7 @@ def test_the_bar_shade_outranks_the_beat_shade_where_they_meet(
assert settings.second_highlight % settings.first_highlight == 0
assert group_color(settings.second_highlight, settings, colors) == colors.rows.bar
- def test_a_metre_the_project_states_moves_the_shades(
+ def test_a_meter_the_project_states_moves_the_shades(
self,
colors: SequencerColors,
) -> None:
diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/test_samples_keys.py b/tests/unit/sampletones_application/ui/panels/sequencer/test_samples_keys.py
deleted file mode 100644
index 2c24e0c12..000000000
--- a/tests/unit/sampletones_application/ui/panels/sequencer/test_samples_keys.py
+++ /dev/null
@@ -1,105 +0,0 @@
-from dataclasses import dataclass, field
-from typing import List, Tuple
-
-import pytest
-
-from sampletones_application.ui.panels.sequencer.samples import GUISequencerSamplesPanel
-from sampletones_application.utils.gui.keyboard.combination import KeyCombination
-from sampletones_application.utils.gui.keyboard.event import KeyEvent
-from sampletones_application.view_model.sequencer.samples import SampleEntryViewModel
-from tests.suite.shortcuts import shipped_source
-
-ENTRIES: Tuple[SampleEntryViewModel, ...] = (
- SampleEntryViewModel(sample_id="kick-id", name="Kick", loop=False),
- SampleEntryViewModel(sample_id="bass-id", name="Bass", loop=True),
- SampleEntryViewModel(sample_id="lead-id", name="Lead", loop=False),
-)
-
-SELECTED_ID = "bass-id"
-SELECTED_ROW = 1
-
-Move = Tuple[str, int]
-
-
-@dataclass
-class SamplesPanelFixture:
- """A panel carrying the state the key path reads, with the calls each action makes recorded."""
-
- panel: GUISequencerSamplesPanel
- removed: List[str] = field(default_factory=list)
- moved: List[Move] = field(default_factory=list)
- renamed: List[str] = field(default_factory=list)
- cancelled: List[None] = field(default_factory=list)
-
-
-@pytest.fixture
-def samples(monkeypatch: pytest.MonkeyPatch) -> SamplesPanelFixture:
- panel = GUISequencerSamplesPanel.__new__(GUISequencerSamplesPanel)
- panel._shortcuts = shipped_source()
- panel._entries = ENTRIES
- panel._selected_sample_id = SELECTED_ID
- panel._selected_row = SELECTED_ROW
- panel._editing_sample_id = None
-
- fixture = SamplesPanelFixture(panel=panel)
- panel.on_remove_requested = fixture.removed.append
- panel.on_move_requested = lambda sample_id, target: fixture.moved.append((sample_id, target))
- monkeypatch.setattr(panel, "_start_rename", fixture.renamed.append)
- monkeypatch.setattr(panel, "_cancel_rename", lambda: fixture.cancelled.append(None))
- return fixture
-
-
-def _press(text: str) -> KeyEvent:
- """The press a written combination names, as the router delivers it."""
- combination = KeyCombination.parse(text)
- return KeyEvent(key=combination.key, modifiers=combination.modifiers)
-
-
-class TestSelectedSampleActions:
- def test_the_remove_key_removes_the_selected_sample(self, samples: SamplesPanelFixture) -> None:
- assert samples.panel._on_key_pressed(_press("Del")) is True
- assert samples.removed == [SELECTED_ID]
-
- def test_the_rename_key_starts_the_rename(self, samples: SamplesPanelFixture) -> None:
- assert samples.panel._on_key_pressed(_press("F2")) is True
- assert samples.renamed == [SELECTED_ID]
-
- def test_a_press_the_panel_leaves_unnamed_reaches_the_application(self, samples: SamplesPanelFixture) -> None:
- assert samples.panel._on_key_pressed(_press("Ctrl+S")) is False
- assert samples.removed == []
-
- def test_a_press_without_a_selection_reaches_the_application(self, samples: SamplesPanelFixture) -> None:
- samples.panel._selected_sample_id = None
-
- assert samples.panel._on_key_pressed(_press("Del")) is False
-
-
-class TestSampleMoves:
- def test_the_move_up_key_moves_the_sample_one_row_back(self, samples: SamplesPanelFixture) -> None:
- assert samples.panel._on_key_pressed(_press("Alt+Up")) is True
- assert samples.moved == [(SELECTED_ID, SELECTED_ROW - 1)]
-
- def test_the_move_to_bottom_key_moves_the_sample_last(self, samples: SamplesPanelFixture) -> None:
- assert samples.panel._on_key_pressed(_press("Alt+End")) is True
- assert samples.moved == [(SELECTED_ID, len(ENTRIES) - 1)]
-
- def test_a_move_with_nowhere_to_go_still_consumes_the_key(self, samples: SamplesPanelFixture) -> None:
- samples.panel._selected_row = 0
-
- assert samples.panel._on_key_pressed(_press("Alt+Up")) is True
- assert samples.moved == []
-
-
-class TestRenameInProgress:
- def test_the_cancel_key_drops_the_name_being_edited(self, samples: SamplesPanelFixture) -> None:
- samples.panel._editing_sample_id = SELECTED_ID
-
- assert samples.panel._on_key_pressed(_press("Esc")) is True
- assert samples.cancelled == [None]
-
- def test_every_other_key_stays_with_the_field(self, samples: SamplesPanelFixture) -> None:
- """A rename keeps the keyboard, so typing a name reaches the input rather than the list."""
- samples.panel._editing_sample_id = SELECTED_ID
-
- assert samples.panel._on_key_pressed(_press("Del")) is False
- assert samples.removed == []
diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/test_samples_menu.py b/tests/unit/sampletones_application/ui/panels/sequencer/test_samples_menu.py
deleted file mode 100644
index 9a8504006..000000000
--- a/tests/unit/sampletones_application/ui/panels/sequencer/test_samples_menu.py
+++ /dev/null
@@ -1,390 +0,0 @@
-import contextlib
-from dataclasses import dataclass, field
-from typing import Any, Callable, Iterator, List, Optional, Tuple
-
-import pytest
-
-from sampletones_application.categories.elements.global_ import ContextElements
-from sampletones_application.categories.elements.sequencer import SequencerInstrumentsElements
-from sampletones_application.ui.elements import context_menu as context_menu_module
-from sampletones_application.ui.elements.fonts.registry import FontRegistry
-from sampletones_application.ui.panels.sequencer import samples as samples_module
-from sampletones_application.ui.panels.sequencer.samples import SAMPLE_MOVES, GUISequencerSamplesPanel
-from sampletones_application.utils.gui.shortcuts.ids import ShortcutId
-from sampletones_application.utils.palette.colors.literal import LiteralColor
-from sampletones_application.view_model.sequencer.samples import SampleEntryViewModel
-from sampletones_application.view_model.shared.footprint import SampleFootprintViewModel
-from sampletones_core.constants.enums import GeneratorName
-from sampletones_core.formats.famitracker.footprint import InstrumentFootprint
-from sampletones_core.utils.display import display_sample_label
-from tests.suite.shortcuts import shipped_source
-
-ENTRIES: Tuple[SampleEntryViewModel, ...] = (
- SampleEntryViewModel(sample_id="kick-id", name="Kick", loop=False),
- SampleEntryViewModel(sample_id="bass-id", name="Bass", loop=True),
- SampleEntryViewModel(sample_id="lead-id", name="Lead", loop=False),
-)
-
-SELECTED_ID = "bass-id"
-SELECTED_ROW = 1
-
-SAMPLE_SIZE_LABEL = "Sample size"
-SIZE_TEMPLATE = "{bytes} B"
-SIZE_TOOLTIP = "Bytes a FamiTracker export spends."
-DETAIL_COLOR = LiteralColor((0, 0, 0, 255))
-
-PULSE_1_FOOTPRINT = InstrumentFootprint(instrument_bytes=9, sequence_bytes=32)
-NOISE_FOOTPRINT = InstrumentFootprint(instrument_bytes=7, sequence_bytes=12)
-PULSE_1_BYTES = PULSE_1_FOOTPRINT.total_bytes
-NOISE_BYTES = NOISE_FOOTPRINT.total_bytes
-FOOTPRINT = SampleFootprintViewModel.from_footprints(
- {
- GeneratorName.PULSE1: PULSE_1_FOOTPRINT,
- GeneratorName.NOISE: NOISE_FOOTPRINT,
- }
-)
-
-EDIT_ITEM = 0
-RENAME_ITEM = 1
-DUPLICATE_ITEM = 2
-REMOVE_ITEM = 3
-MOVE_UP_ITEM = 4
-MOVE_DOWN_ITEM = 5
-MOVE_TOP_ITEM = 6
-MOVE_BOTTOM_ITEM = 7
-
-
-@dataclass
-class MenuItem:
- """One item as it was registered, which is the whole of what a reader sees and clicks."""
-
- label: str
- shortcut: str
- enabled: bool
- callback: Callable[[], None]
-
-
-@dataclass
-class Requests:
- """What each sample hook was handed when its menu item fired."""
-
- edited: List[str] = field(default_factory=list)
- renamed: List[str] = field(default_factory=list)
- duplicated: List[str] = field(default_factory=list)
- removed: List[str] = field(default_factory=list)
- moved: List[Tuple[str, Optional[int]]] = field(default_factory=list)
-
-
-class _MenuRecorder:
- def __init__(self) -> None:
- self.items: List[MenuItem] = []
-
- def add_menu_item(self, **kwargs: Any) -> int:
- self.items.append(
- MenuItem(
- label=kwargs["label"],
- shortcut=kwargs.get("shortcut", ""),
- enabled=kwargs.get("enabled", True),
- callback=kwargs["callback"],
- )
- )
- return 0
-
-
-@pytest.fixture
-def recorder(monkeypatch: pytest.MonkeyPatch) -> _MenuRecorder:
- recorded = _MenuRecorder()
- monkeypatch.setattr(samples_module.dpg, "add_menu_item", recorded.add_menu_item)
- monkeypatch.setattr(samples_module.dpg, "add_separator", lambda **_kwargs: 0)
- return recorded
-
-
-@dataclass
-class SamplesPanelFixture:
- """A panel holding a selection, with the calls each menu item makes recorded."""
-
- panel: GUISequencerSamplesPanel
- requests: Requests
-
-
-def _panel(
- monkeypatch: pytest.MonkeyPatch,
- *,
- selected_row: Optional[int] = SELECTED_ROW,
- tab_active: bool = True,
- editing: Optional[str] = None,
- field_focused: bool = False,
- footprint: Optional[SampleFootprintViewModel] = FOOTPRINT,
- footprint_wired: bool = True,
-) -> SamplesPanelFixture:
- """A samples panel whose menu builder can run with no DearPyGui context behind it."""
- panel = GUISequencerSamplesPanel.__new__(GUISequencerSamplesPanel)
- panel._language_manager = _Labels()
- panel._shortcuts = shipped_source()
- panel._entries = ENTRIES
- panel._selected_sample_id = None if selected_row is None else SELECTED_ID
- panel._selected_row = selected_row
- panel._editing_sample_id = editing
- panel._tab_active = lambda: tab_active
- panel._router = _Router(field_focused=field_focused)
- panel._detail_color = DETAIL_COLOR
- panel._lbl_sample_size = SAMPLE_SIZE_LABEL
- panel._tpl_size_bytes = SIZE_TEMPLATE
- panel._tip_size_bytes = SIZE_TOOLTIP
- panel.sample_footprint = (lambda _sample_id: footprint) if footprint_wired else None
-
- requests = Requests()
- panel.on_sample_edit_requested = requests.edited.append
- panel.on_duplicate_requested = requests.duplicated.append
- panel.on_remove_requested = requests.removed.append
- panel.on_move_requested = lambda sample_id, target: requests.moved.append((sample_id, target))
- monkeypatch.setattr(panel, "_start_rename", requests.renamed.append)
- return SamplesPanelFixture(panel=panel, requests=requests)
-
-
-class _Labels:
- """A language manager printing each key's own element, so an item reads as the action it names."""
-
- def __getitem__(self, key: Tuple[Any, ...]) -> str:
- return str(key[-1].value)
-
-
-@dataclass(frozen=True)
-class MenuWidget:
- """One widget as the menu registered it, which is the whole of what a reader meets."""
-
- kind: str
- text: str
-
-
-class _MenuBuildRecorder:
- """Every widget a whole menu build registers, in the order they are printed."""
-
- def __init__(self) -> None:
- self.widgets: List[MenuWidget] = []
- self.tooltips: List[str] = []
-
- def add_text(self, text: str, **_kwargs: Any) -> int:
- self.widgets.append(MenuWidget(kind="text", text=text))
- return 0
-
- def add_tooltip(self, _parent: int, message: str, **_kwargs: Any) -> int:
- self.tooltips.append(message)
- return 0
-
- def add_separator(self, **_kwargs: Any) -> int:
- self.widgets.append(MenuWidget(kind="separator", text=""))
- return 0
-
- def add_menu_item(self, **kwargs: Any) -> int:
- self.widgets.append(MenuWidget(kind="item", text=kwargs["label"]))
- return 0
-
- def texts_before_the_first_item(self) -> List[str]:
- widgets: List[str] = []
- for widget in self.widgets:
- if widget.kind == "item":
- break
- if widget.kind == "text":
- widgets.append(widget.text)
-
- return widgets
-
-
-@contextlib.contextmanager
-def _null_menu() -> Iterator[None]:
- yield
-
-
-@pytest.fixture
-def build_recorder(monkeypatch: pytest.MonkeyPatch) -> _MenuBuildRecorder:
- """Records a whole context-menu build, with the DearPyGui calls behind it stood down."""
- recorded = _MenuBuildRecorder()
- monkeypatch.setattr(samples_module.dpg, "add_text", recorded.add_text)
- monkeypatch.setattr(samples_module.dpg, "add_separator", recorded.add_separator)
- monkeypatch.setattr(samples_module.dpg, "add_menu_item", recorded.add_menu_item)
- monkeypatch.setattr(samples_module, "context_menu", _null_menu)
- monkeypatch.setattr(context_menu_module, "dpg_set_palette_color", lambda _item, _color: None)
- monkeypatch.setattr(context_menu_module, "show_tooltip", recorded.add_tooltip)
- monkeypatch.setattr(FontRegistry, "bind_to_item", lambda _item, _font: None)
- return recorded
-
-
-@dataclass(frozen=True)
-class _Router:
- """The key router as the panel's own scope reads it."""
-
- field_focused: bool
-
- @property
- def is_field_focused(self) -> bool:
- return self.field_focused
-
-
-class TestActionItems:
- def test_the_menu_reads_as_the_sample_actions(
- self,
- monkeypatch: pytest.MonkeyPatch,
- recorder: _MenuRecorder,
- ) -> None:
- _panel(monkeypatch).panel.build_edit_actions()
-
- assert [item.label for item in recorder.items] == [
- SequencerInstrumentsElements.CONTEXT_EDIT.value,
- SequencerInstrumentsElements.CONTEXT_RENAME.value,
- SequencerInstrumentsElements.CONTEXT_DUPLICATE.value,
- SequencerInstrumentsElements.CONTEXT_REMOVE.value,
- *(move.element.value for move in SAMPLE_MOVES),
- ]
-
- def test_the_items_print_the_keys_the_panel_answers_to(
- self,
- monkeypatch: pytest.MonkeyPatch,
- recorder: _MenuRecorder,
- ) -> None:
- """The panel has always answered these presses, and an item prints the one it fires."""
- shortcuts = shipped_source()
- _panel(monkeypatch).panel.build_edit_actions()
-
- assert recorder.items[RENAME_ITEM].shortcut == shortcuts.display(ShortcutId.SAMPLES_RENAME_SAMPLE)
- assert recorder.items[REMOVE_ITEM].shortcut == shortcuts.display(ShortcutId.SAMPLES_REMOVE_SAMPLE)
- assert [item.shortcut for item in recorder.items[MOVE_UP_ITEM:]] == [
- shortcuts.display(move.shortcut) for move in SAMPLE_MOVES
- ]
-
- def test_the_items_act_on_the_sample_they_were_raised_on(
- self,
- monkeypatch: pytest.MonkeyPatch,
- recorder: _MenuRecorder,
- ) -> None:
- fixture = _panel(monkeypatch)
- fixture.panel.build_edit_actions()
-
- for item in recorder.items:
- item.callback()
-
- assert fixture.requests.edited == [SELECTED_ID]
- assert fixture.requests.renamed == [SELECTED_ID]
- assert fixture.requests.duplicated == [SELECTED_ID]
- assert fixture.requests.removed == [SELECTED_ID]
- assert fixture.requests.moved == [
- (SELECTED_ID, SELECTED_ROW - 1),
- (SELECTED_ID, SELECTED_ROW + 1),
- (SELECTED_ID, 0),
- (SELECTED_ID, len(ENTRIES) - 1),
- ]
-
- def test_a_move_with_nowhere_to_go_is_greyed_out(
- self,
- monkeypatch: pytest.MonkeyPatch,
- recorder: _MenuRecorder,
- ) -> None:
- _panel(monkeypatch, selected_row=0).panel.build_edit_actions()
-
- assert not recorder.items[MOVE_UP_ITEM].enabled
- assert not recorder.items[MOVE_TOP_ITEM].enabled
- assert recorder.items[MOVE_DOWN_ITEM].enabled
- assert recorder.items[MOVE_BOTTOM_ITEM].enabled
-
-
-class TestTheSizeRows:
- """A sample's menu names the bytes it occupies, so what a pool costs is read where it is edited."""
-
- def test_the_rows_read_as_the_total_then_each_playing_channel(self, monkeypatch: pytest.MonkeyPatch) -> None:
- items = _panel(monkeypatch).panel._footprint_items(SELECTED_ID)
-
- assert items == [
- (SAMPLE_SIZE_LABEL, f"{PULSE_1_BYTES + NOISE_BYTES} B"),
- (ContextElements.PULSE_1.value, f"{PULSE_1_BYTES} B"),
- (ContextElements.NOISE.value, f"{NOISE_BYTES} B"),
- ]
-
- def test_a_channel_standing_by_is_named_nowhere(self, monkeypatch: pytest.MonkeyPatch) -> None:
- """A channel that does not play is written by no export, so it costs nothing to name."""
- labels = [label for label, _value in _panel(monkeypatch).panel._footprint_items(SELECTED_ID)]
-
- assert ContextElements.PULSE_2.value not in labels
- assert ContextElements.TRIANGLE.value not in labels
-
- def test_the_figures_name_the_sample_the_pointer_landed_on(self, monkeypatch: pytest.MonkeyPatch) -> None:
- """The figures are asked for as the menu opens, so they answer for the row right-clicked."""
- measured: List[str] = []
-
- def _measure(sample_id: str) -> SampleFootprintViewModel:
- measured.append(sample_id)
- return FOOTPRINT
-
- fixture = _panel(monkeypatch)
- fixture.panel.sample_footprint = _measure
-
- fixture.panel._footprint_items("lead-id")
-
- assert measured == ["lead-id"]
-
- def test_a_sample_the_pool_has_dropped_prints_no_rows(self, monkeypatch: pytest.MonkeyPatch) -> None:
- assert _panel(monkeypatch, footprint=None).panel._footprint_items(SELECTED_ID) == []
-
- def test_an_unwired_hook_prints_no_rows(self, monkeypatch: pytest.MonkeyPatch) -> None:
- """A panel tolerates its hooks being unset until the coordinator wires them."""
- assert _panel(monkeypatch, footprint_wired=False).panel._footprint_items(SELECTED_ID) == []
-
-
-class TestMenuComposition:
- def test_the_sizes_sit_between_the_sample_name_and_the_actions(
- self,
- monkeypatch: pytest.MonkeyPatch,
- build_recorder: _MenuBuildRecorder,
- ) -> None:
- """Pins where the figures are printed: under the name they belong to, above what can be done."""
- _panel(monkeypatch).panel._show_context_menu(SELECTED_ROW, SELECTED_ID)
-
- assert build_recorder.texts_before_the_first_item() == [
- display_sample_label(SELECTED_ROW, "Bass"),
- f"{SAMPLE_SIZE_LABEL}: {PULSE_1_BYTES + NOISE_BYTES} B",
- f"{ContextElements.PULSE_1.value}: {PULSE_1_BYTES} B",
- f"{ContextElements.NOISE.value}: {NOISE_BYTES} B",
- ]
-
- def test_every_figure_names_the_export_it_measures(
- self,
- monkeypatch: pytest.MonkeyPatch,
- build_recorder: _MenuBuildRecorder,
- ) -> None:
- """A byte count means one export, so each line a reader hovers says which one it counts."""
- _panel(monkeypatch).panel._show_context_menu(SELECTED_ROW, SELECTED_ID)
-
- assert build_recorder.tooltips == [SIZE_TOOLTIP] * 3
-
- def test_a_menu_with_no_figures_reads_as_it_always_has(
- self,
- monkeypatch: pytest.MonkeyPatch,
- build_recorder: _MenuBuildRecorder,
- ) -> None:
- _panel(monkeypatch, footprint=None).panel._show_context_menu(SELECTED_ROW, SELECTED_ID)
-
- assert build_recorder.texts_before_the_first_item() == [display_sample_label(SELECTED_ROW, "Bass")]
-
-
-class TestEditActions:
- def test_the_panel_answers_while_it_holds_a_selection(self, monkeypatch: pytest.MonkeyPatch) -> None:
- assert _panel(monkeypatch).panel.owns_edit_actions()
-
- def test_a_panel_holding_no_selection_stands_down(self, monkeypatch: pytest.MonkeyPatch) -> None:
- """The grids and this panel hold one selection between them, so one of them answers."""
- assert not _panel(monkeypatch, selected_row=None).panel.owns_edit_actions()
-
- def test_a_panel_on_a_tab_behind_stands_down(self, monkeypatch: pytest.MonkeyPatch) -> None:
- """A selection outlives a move to another tab, and the Edit menu follows the tab in front."""
- assert not _panel(monkeypatch, tab_active=False).panel.owns_edit_actions()
-
- def test_a_field_holding_the_keyboard_stands_the_panel_down(self, monkeypatch: pytest.MonkeyPatch) -> None:
- assert not _panel(monkeypatch, field_focused=True).panel.owns_edit_actions()
-
- def test_a_panel_holding_no_selection_builds_nothing(
- self,
- monkeypatch: pytest.MonkeyPatch,
- recorder: _MenuRecorder,
- ) -> None:
- _panel(monkeypatch, selected_row=None).panel.build_edit_actions()
-
- assert recorder.items == []
diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/test_selection_drag.py b/tests/unit/sampletones_application/ui/panels/sequencer/test_selection_drag.py
index 97353acc9..f086be604 100644
--- a/tests/unit/sampletones_application/ui/panels/sequencer/test_selection_drag.py
+++ b/tests/unit/sampletones_application/ui/panels/sequencer/test_selection_drag.py
@@ -28,19 +28,19 @@
OrderInputState,
)
from sampletones_application.ui.panels.sequencer.input.tracker import TrackerCursor, TrackerInputState
-from sampletones_application.ui.panels.sequencer.order import GUISequencerOrderPanel, OrderKey
-from sampletones_application.ui.panels.sequencer.tracker import CellKey, GUISequencerTrackerPanel
+from sampletones_application.ui.panels.sequencer.order.panel import GUISequencerOrderPanel, OrderKey
+from sampletones_application.ui.panels.sequencer.tracker.panel import CellKey, GUISequencerTrackerPanel
from sampletones_application.utils.gui.keyboard.modifiers import Modifier
from sampletones_application.utils.palette.catalog import PaletteCatalog
from sampletones_application.utils.palette.source import PaletteSource
from sampletones_application.view_model.sequencer.region import OrderRegion, TrackerRegion
from sampletones_application.view_model.sequencer.subcolumn import SubColumn
-from sampletones_core.constants.enums import GeneratorName
+from sampletones_core.constants.enums import ChannelName
ROW_COUNT = 64
POSITION_COUNT = 8
ORIGIN_WIDGET = 101
-ORIGIN_CELL: CellKey = (2, GeneratorName.PULSE1, SubColumn.TRANSPOSE)
+ORIGIN_CELL: CellKey = (2, ChannelName.PULSE1, SubColumn.TRANSPOSE)
ORIGIN_ENTRY: OrderKey = (None, 1)
@@ -97,7 +97,7 @@ def _tracker(
states: List[TrackerInputState] = []
monkeypatch.setattr(panel, "_apply_state", states.append)
monkeypatch.setattr(panel, "_cell_at", lambda: reached)
- _hold_modifiers(monkeypatch, "tracker", shift)
+ _hold_modifiers(monkeypatch, "tracker.panel", shift)
return panel, states
@@ -121,7 +121,7 @@ def _order(
states: List[OrderInputState] = []
monkeypatch.setattr(panel, "_apply_state", states.append)
monkeypatch.setattr(panel, "_cell_at", lambda: reached)
- _hold_modifiers(monkeypatch, "order", shift)
+ _hold_modifiers(monkeypatch, "order.panel", shift)
return panel, states
@@ -152,7 +152,7 @@ def test_a_press_held_on_its_own_cell_stays_a_click(self, monkeypatch: pytest.Mo
assert states == []
def test_a_drag_anchors_at_the_pressed_cell(self, monkeypatch: pytest.MonkeyPatch) -> None:
- reached: CellKey = (5, GeneratorName.TRIANGLE, SubColumn.VOLUME)
+ reached: CellKey = (5, ChannelName.TRIANGLE, SubColumn.VOLUME)
panel, states = _tracker(monkeypatch, reached=reached)
panel._on_cell_held(0, ORIGIN_WIDGET)
@@ -166,11 +166,11 @@ def test_a_drag_anchors_at_the_pressed_cell(self, monkeypatch: pytest.MonkeyPatc
)
def test_a_plain_drag_replaces_the_selection_already_held(self, monkeypatch: pytest.MonkeyPatch) -> None:
- reached: CellKey = (5, GeneratorName.PULSE1, SubColumn.TRANSPOSE)
+ reached: CellKey = (5, ChannelName.PULSE1, SubColumn.TRANSPOSE)
panel, states = _tracker(monkeypatch, reached=reached)
panel._input_state = TrackerInputState(
- cursor=TrackerCursor(20, GeneratorName.NOISE, SubColumn.VOLUME),
- anchor=TrackerCursor(30, GeneratorName.NOISE, SubColumn.VOLUME),
+ cursor=TrackerCursor(20, ChannelName.NOISE, SubColumn.VOLUME),
+ anchor=TrackerCursor(30, ChannelName.NOISE, SubColumn.VOLUME),
)
panel._on_cell_held(0, ORIGIN_WIDGET)
@@ -185,20 +185,20 @@ def test_a_plain_drag_replaces_the_selection_already_held(self, monkeypatch: pyt
)
def test_a_shift_press_carries_the_selection_already_held(self, monkeypatch: pytest.MonkeyPatch) -> None:
- reached: CellKey = (5, GeneratorName.PULSE1, SubColumn.TRANSPOSE)
+ reached: CellKey = (5, ChannelName.PULSE1, SubColumn.TRANSPOSE)
panel, states = _tracker(monkeypatch, reached=reached, shift=True)
panel._input_state = TrackerInputState(
- cursor=TrackerCursor(9, GeneratorName.PULSE1, SubColumn.TRANSPOSE),
- anchor=TrackerCursor(9, GeneratorName.PULSE2, SubColumn.TRANSPOSE),
+ cursor=TrackerCursor(9, ChannelName.PULSE1, SubColumn.TRANSPOSE),
+ anchor=TrackerCursor(9, ChannelName.PULSE2, SubColumn.TRANSPOSE),
)
panel._on_cell_held(0, ORIGIN_WIDGET)
panel._on_cell_held(0, ORIGIN_WIDGET)
- assert states[-1].anchor == TrackerCursor(9, GeneratorName.PULSE2, SubColumn.TRANSPOSE)
+ assert states[-1].anchor == TrackerCursor(9, ChannelName.PULSE2, SubColumn.TRANSPOSE)
def test_a_drag_back_to_its_origin_selects_that_cell(self, monkeypatch: pytest.MonkeyPatch) -> None:
- reached: CellKey = (5, GeneratorName.PULSE1, SubColumn.TRANSPOSE)
+ reached: CellKey = (5, ChannelName.PULSE1, SubColumn.TRANSPOSE)
panel, states = _tracker(monkeypatch, reached=reached)
panel._on_cell_held(0, ORIGIN_WIDGET)
@@ -223,7 +223,7 @@ def test_a_press_on_a_cell_the_cache_forgot_selects_nothing(self, monkeypatch: p
def test_a_new_press_ends_the_gesture_before_it(self, monkeypatch: pytest.MonkeyPatch) -> None:
"""The press starting a gesture ends the one before it, so its click places the cursor."""
- reached: CellKey = (5, GeneratorName.PULSE1, SubColumn.TRANSPOSE)
+ reached: CellKey = (5, ChannelName.PULSE1, SubColumn.TRANSPOSE)
panel, states = _tracker(monkeypatch, reached=reached)
_silence_click(monkeypatch)
@@ -240,7 +240,7 @@ def test_the_click_ending_a_drag_leaves_the_selection_alone(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""A drag returning to its own cell releases there, and that release reports a click."""
- reached: CellKey = (5, GeneratorName.PULSE1, SubColumn.TRANSPOSE)
+ reached: CellKey = (5, ChannelName.PULSE1, SubColumn.TRANSPOSE)
panel, states = _tracker(monkeypatch, reached=reached)
_silence_click(monkeypatch)
@@ -377,7 +377,7 @@ def test_a_press_alone_selects_nothing(self, monkeypatch: pytest.MonkeyPatch) ->
assert states == []
def test_a_drag_anchors_at_the_pressed_cell(self, monkeypatch: pytest.MonkeyPatch) -> None:
- reached: OrderKey = (GeneratorName.PULSE2, 4)
+ reached: OrderKey = (ChannelName.PULSE2, 4)
panel, states = _order(monkeypatch, reached=reached)
panel._on_cell_held(0, ORIGIN_WIDGET)
@@ -391,21 +391,21 @@ def test_a_drag_anchors_at_the_pressed_cell(self, monkeypatch: pytest.MonkeyPatc
)
def test_a_shift_press_carries_the_selection_already_held(self, monkeypatch: pytest.MonkeyPatch) -> None:
- reached: OrderKey = (GeneratorName.PULSE2, 4)
+ reached: OrderKey = (ChannelName.PULSE2, 4)
panel, states = _order(monkeypatch, reached=reached, shift=True)
panel._input_state = OrderInputState(
- cursor=OrderCursor(GeneratorName.NOISE, 6),
- anchor=OrderCursor(GeneratorName.NOISE, 6),
+ cursor=OrderCursor(ChannelName.NOISE, 6),
+ anchor=OrderCursor(ChannelName.NOISE, 6),
)
panel._on_cell_held(0, ORIGIN_WIDGET)
panel._on_cell_held(0, ORIGIN_WIDGET)
- assert states[-1].anchor == OrderCursor(GeneratorName.NOISE, 6)
+ assert states[-1].anchor == OrderCursor(ChannelName.NOISE, 6)
def test_a_new_press_ends_the_gesture_before_it(self, monkeypatch: pytest.MonkeyPatch) -> None:
"""The press starting a gesture ends the one before it, so its click places the cursor."""
- reached: OrderKey = (GeneratorName.PULSE2, 4)
+ reached: OrderKey = (ChannelName.PULSE2, 4)
panel, states = _order(monkeypatch, reached=reached)
_silence_click(monkeypatch)
@@ -421,7 +421,7 @@ def test_the_click_ending_a_drag_leaves_the_selection_alone(
self,
monkeypatch: pytest.MonkeyPatch,
) -> None:
- reached: OrderKey = (GeneratorName.PULSE2, 4)
+ reached: OrderKey = (ChannelName.PULSE2, 4)
panel, states = _order(monkeypatch, reached=reached)
_silence_click(monkeypatch)
diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/test_selection_keys.py b/tests/unit/sampletones_application/ui/panels/sequencer/test_selection_keys.py
index 2b2043bfc..343b0f5d0 100644
--- a/tests/unit/sampletones_application/ui/panels/sequencer/test_selection_keys.py
+++ b/tests/unit/sampletones_application/ui/panels/sequencer/test_selection_keys.py
@@ -8,14 +8,14 @@
OrderInputState,
)
from sampletones_application.ui.panels.sequencer.input.tracker import TrackerCursor, TrackerInputState
-from sampletones_application.ui.panels.sequencer.order import GUISequencerOrderPanel
-from sampletones_application.ui.panels.sequencer.tracker import GUISequencerTrackerPanel
+from sampletones_application.ui.panels.sequencer.order.panel import GUISequencerOrderPanel
+from sampletones_application.ui.panels.sequencer.tracker.panel import GUISequencerTrackerPanel
from sampletones_application.utils.gui.keyboard.combination import KeyCombination
from sampletones_application.utils.gui.keyboard.event import KeyEvent
from sampletones_application.view_model.sequencer.region import OrderRegion, TrackerRegion
from sampletones_application.view_model.sequencer.slot import SLOT_COUNT, TrackerSlot
from sampletones_application.view_model.sequencer.subcolumn import SubColumn
-from sampletones_core.constants.enums import GeneratorName
+from sampletones_core.constants.enums import ChannelName
from tests.suite.shortcuts import shipped_source
ROW_COUNT = 64
@@ -31,20 +31,20 @@ def _press(text: str) -> KeyEvent:
def _tracker(
- generator: Optional[GeneratorName] = GeneratorName.PULSE1,
- subcolumn: SubColumn = SubColumn.INSTRUMENT,
+ channel: Optional[ChannelName] = ChannelName.PULSE1,
+ subcolumn: SubColumn = SubColumn.VOICE,
) -> GUISequencerTrackerPanel:
panel = GUISequencerTrackerPanel.__new__(GUISequencerTrackerPanel)
panel._shortcuts = shipped_source()
- panel._input_state = TrackerInputState(cursor=TrackerCursor(CURSOR_ROW, generator, subcolumn))
+ panel._input_state = TrackerInputState(cursor=TrackerCursor(CURSOR_ROW, channel, subcolumn))
panel._current_row_count = ROW_COUNT
return panel
-def _order(generator: Optional[GeneratorName] = GeneratorName.PULSE1) -> GUISequencerOrderPanel:
+def _order(channel: Optional[ChannelName] = ChannelName.PULSE1) -> GUISequencerOrderPanel:
panel = GUISequencerOrderPanel.__new__(GUISequencerOrderPanel)
panel._shortcuts = shipped_source()
- panel._input_state = OrderInputState(cursor=OrderCursor(generator, CURSOR_POSITION))
+ panel._input_state = OrderInputState(cursor=OrderCursor(channel, CURSOR_POSITION))
panel._position_count = POSITION_COUNT
return panel
@@ -101,8 +101,8 @@ def test_shift_right_selects_the_next_subcolumn(self, monkeypatch: pytest.Monkey
region = states[-1].region
assert region is not None
assert region.slots == (
- TrackerSlot(GeneratorName.PULSE1, SubColumn.INSTRUMENT),
- TrackerSlot(GeneratorName.PULSE1, SubColumn.TRANSPOSE),
+ TrackerSlot(ChannelName.PULSE1, SubColumn.VOICE),
+ TrackerSlot(ChannelName.PULSE1, SubColumn.TRANSPOSE),
)
def test_shift_end_selects_to_the_last_row(self, monkeypatch: pytest.MonkeyPatch) -> None:
@@ -153,7 +153,7 @@ def test_shift_up_selects_up_to_the_master_row(self, monkeypatch: pytest.MonkeyP
assert panel._on_key_pressed(_press("Shift+Up")) is True
region = states[-1].region
assert region is not None
- assert region.generators == (None, GeneratorName.PULSE1)
+ assert region.channels == (None, ChannelName.PULSE1)
def test_shift_end_selects_to_the_last_position(self, monkeypatch: pytest.MonkeyPatch) -> None:
panel = _order()
@@ -195,13 +195,13 @@ def test_ctrl_a_selects_the_whole_frame(self, monkeypatch: pytest.MonkeyPatch) -
assert (region.first_slot, region.last_slot) == (0, SLOT_COUNT - 1)
def test_ctrl_shift_a_selects_the_column_the_cursor_stands_in(self, monkeypatch: pytest.MonkeyPatch) -> None:
- panel = _tracker(generator=GeneratorName.TRIANGLE, subcolumn=SubColumn.VOLUME)
+ panel = _tracker(channel=ChannelName.TRIANGLE, subcolumn=SubColumn.VOLUME)
states = _tracker_states(monkeypatch, panel)
assert panel._on_key_pressed(_press("Ctrl+Shift+A")) is True
region = states[-1].region
assert region is not None
- assert region.slots == tuple(TrackerSlot(GeneratorName.TRIANGLE, subcolumn) for subcolumn in SubColumn)
+ assert region.slots == tuple(TrackerSlot(ChannelName.TRIANGLE, subcolumn) for subcolumn in SubColumn)
def test_ctrl_alt_a_selects_the_subcolumn_the_cursor_stands_in(self, monkeypatch: pytest.MonkeyPatch) -> None:
panel = _tracker(subcolumn=SubColumn.VOLUME)
@@ -210,7 +210,7 @@ def test_ctrl_alt_a_selects_the_subcolumn_the_cursor_stands_in(self, monkeypatch
assert panel._on_key_pressed(_press("Ctrl+Alt+A")) is True
region = states[-1].region
assert region is not None
- assert region.slots == (TrackerSlot(GeneratorName.PULSE1, SubColumn.VOLUME),)
+ assert region.slots == (TrackerSlot(ChannelName.PULSE1, SubColumn.VOLUME),)
def test_a_shape_stands_the_cursor_at_the_end_it_reaches(self, monkeypatch: pytest.MonkeyPatch) -> None:
"""A Shift+Up straight after shrinks the selection from the row the shape ended on."""
@@ -218,7 +218,7 @@ def test_a_shape_stands_the_cursor_at_the_end_it_reaches(self, monkeypatch: pyte
states = _tracker_states(monkeypatch, panel)
assert panel._on_key_pressed(_press("Ctrl+A")) is True
- assert states[-1].cursor == TrackerCursor(ROW_COUNT - 1, GeneratorName.NOISE, SubColumn.VOLUME)
+ assert states[-1].cursor == TrackerCursor(ROW_COUNT - 1, ChannelName.NOISE, SubColumn.VOLUME)
class TestOrderSelectKeys:
@@ -231,17 +231,17 @@ def test_ctrl_a_selects_the_whole_order(self, monkeypatch: pytest.MonkeyPatch) -
assert panel._on_key_pressed(_press("Ctrl+A")) is True
region = states[-1].region
assert region is not None
- assert region.generators == CHANNEL_AXIS
+ assert region.channels == CHANNEL_AXIS
assert (region.first_position, region.last_position) == (0, POSITION_COUNT - 1)
def test_ctrl_shift_a_selects_the_row_the_cursor_stands_in(self, monkeypatch: pytest.MonkeyPatch) -> None:
- panel = _order(generator=None)
+ panel = _order(channel=None)
states = _order_states(monkeypatch, panel)
assert panel._on_key_pressed(_press("Ctrl+Shift+A")) is True
region = states[-1].region
assert region is not None
- assert region.generators == (None,)
+ assert region.channels == (None,)
assert (region.first_position, region.last_position) == (0, POSITION_COUNT - 1)
def test_a_shape_stands_the_cursor_at_the_end_it_reaches(self, monkeypatch: pytest.MonkeyPatch) -> None:
diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_cell_themes.py b/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_cell_themes.py
new file mode 100644
index 000000000..b90586d25
--- /dev/null
+++ b/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_cell_themes.py
@@ -0,0 +1,299 @@
+from types import SimpleNamespace
+from typing import Any, Dict, FrozenSet, List, Optional, Tuple
+
+import pytest
+
+from sampletones_application.ui.elements.table.cells import EditableCells
+from sampletones_application.ui.panels.sequencer.display import CellKey, CellKinds
+from sampletones_application.ui.panels.sequencer.tracker import panel as tracker_module
+from sampletones_application.ui.panels.sequencer.tracker.callbacks import ThemeKey
+from sampletones_application.ui.panels.sequencer.tracker.panel import GUISequencerTrackerPanel
+from sampletones_application.ui.panels.sequencer.tracker.themes import TrackerThemes
+from sampletones_application.utils.palette.colors.base import BaseColor
+from sampletones_application.utils.palette.colors.written import LiteralColor
+from sampletones_application.view_model.sequencer.channels import (
+ SequencerChannelsViewModel,
+)
+from sampletones_application.view_model.sequencer.subcolumn import SubColumn
+from sampletones_application.view_model.sequencer.tracker import (
+ SequencerCellViewModel,
+ SequencerRowViewModel,
+ SequencerTrackerViewModel,
+)
+from sampletones_application.view_model.sequencer.voices import VoiceKind
+from sampletones_core.constants.enums import ChannelName
+from sampletones_core.utils.display import display_id, display_transpose, display_volume
+from sampletones_shared.types.application import Sender
+
+ROW_COUNT = 2
+MUTED_TEXT_FRACTION = 0.25
+
+THEME_IDS: Dict[ThemeKey, int] = {
+ (SubColumn.VOICE, None): 10,
+ (SubColumn.VOICE, VoiceKind.SAMPLE): 11,
+ (SubColumn.VOICE, VoiceKind.INSTRUMENT): 12,
+ (SubColumn.TRANSPOSE, None): 13,
+ (SubColumn.VOLUME, None): 14,
+}
+MUTED_THEME_IDS: Dict[ThemeKey, int] = {theme_key: theme + 100 for theme_key, theme in THEME_IDS.items()}
+
+TEXT_COLORS = SimpleNamespace(
+ voice=LiteralColor((118, 122, 142, 255)),
+ sample=LiteralColor((224, 200, 96, 255)),
+ instrument=LiteralColor((255, 112, 223, 255)),
+ transpose=LiteralColor((192, 192, 192, 255)),
+ volume=LiteralColor((100, 220, 100, 255)),
+)
+
+
+def _cell_widget(key: CellKey) -> int:
+ """A stable stand-in widget id per cell."""
+ row_index, channel, subcolumn = key
+ column = 0 if channel is None else ChannelName.items().index(channel) + 1
+ return 1000 + 100 * column + 10 * row_index + list(SubColumn).index(subcolumn)
+
+
+def _keys() -> List[CellKey]:
+ return [
+ (row_index, channel, subcolumn)
+ for row_index in range(ROW_COUNT)
+ for channel in (None, *ChannelName.items())
+ for subcolumn in SubColumn
+ ]
+
+
+def _themes(
+ subcolumn: Dict[ThemeKey, int],
+ muted_subcolumn: Dict[ThemeKey, int],
+ *,
+ header: int = 0,
+ muted_header: int = 0,
+) -> TrackerThemes:
+ """A themes object holding the ids under test, standing in for ones DearPyGui built."""
+ themes = TrackerThemes.__new__(TrackerThemes)
+ themes._subcolumn = dict(subcolumn)
+ themes._muted_subcolumn = dict(muted_subcolumn)
+ themes._header = header
+ themes._muted_header = muted_header
+ return themes
+
+
+def _panel(
+ *,
+ cell_kinds: Optional[CellKinds] = None,
+ muted: FrozenSet[ChannelName] = frozenset(),
+) -> GUISequencerTrackerPanel:
+ """Builds a panel around the state a cell's theme is read from, with no DearPyGui context."""
+ panel = GUISequencerTrackerPanel.__new__(GUISequencerTrackerPanel)
+ panel._layout = SimpleNamespace(
+ colors=SimpleNamespace(text=TEXT_COLORS),
+ tracker=SimpleNamespace(muted_text_fraction=MUTED_TEXT_FRACTION),
+ )
+ panel._cell_kinds = dict(cell_kinds or {})
+ panel._themes = _themes(THEME_IDS, MUTED_THEME_IDS)
+ panel._current_channels = SequencerChannelsViewModel(muted=muted)
+ panel._current_row_count = ROW_COUNT
+ panel._editable_cells = EditableCells()
+ for key in _keys():
+ panel._editable_cells.register(key, _cell_widget(key))
+
+ return panel
+
+
+@pytest.fixture
+def bound(monkeypatch: pytest.MonkeyPatch) -> Dict[Sender, int]:
+ """The theme each widget was last bound to."""
+ themes: Dict[Sender, int] = {}
+ monkeypatch.setattr(tracker_module.dpg, "bind_item_theme", themes.__setitem__)
+ return themes
+
+
+def _view_model(rows: Tuple[SequencerRowViewModel, ...]) -> SequencerTrackerViewModel:
+ return SequencerTrackerViewModel(frame_index=0, frame_count=1, rows=rows)
+
+
+def _row(
+ index: int,
+ *,
+ named: Optional[Tuple[ChannelName, VoiceKind]] = None,
+) -> SequencerRowViewModel:
+ """A row whose channels stand empty, save one naming a voice of the given kind."""
+ cells = {
+ channel: SequencerCellViewModel(
+ voice=display_id(None),
+ transpose=display_transpose(None),
+ volume=display_volume(None),
+ kind=None,
+ )
+ for channel in ChannelName.items()
+ }
+ sample_channels: FrozenSet[ChannelName] = frozenset()
+ if named is not None:
+ channel, kind = named
+ cells[channel] = SequencerCellViewModel(
+ voice=display_id(0),
+ transpose=display_transpose(None),
+ volume=display_volume(None),
+ kind=kind,
+ )
+ if kind is VoiceKind.SAMPLE:
+ sample_channels = frozenset({channel})
+
+ return SequencerRowViewModel(index=index, cells=cells, sample_channels=sample_channels)
+
+
+class TestWhatColorAVoiceSlotWears:
+ """The slot takes the color of the kind standing in it, so the grid reports what it holds."""
+
+ def test_a_slot_naming_nothing_takes_the_neutral_shade(self) -> None:
+ panel = _panel()
+
+ theme = panel._cell_theme((0, ChannelName.PULSE1, SubColumn.VOICE))
+
+ assert theme == THEME_IDS[(SubColumn.VOICE, None)]
+
+ @pytest.mark.parametrize(
+ "kind",
+ [VoiceKind.SAMPLE, VoiceKind.INSTRUMENT],
+ ids=lambda kind: kind.value,
+ )
+ def test_a_slot_naming_a_voice_takes_that_kinds_color(self, kind: VoiceKind) -> None:
+ key = (0, ChannelName.PULSE1, SubColumn.VOICE)
+ panel = _panel(cell_kinds={key: kind})
+
+ assert panel._cell_theme(key) == THEME_IDS[(SubColumn.VOICE, kind)]
+
+ def test_the_sample_column_takes_the_kind_its_own_slot_names(self) -> None:
+ key = (0, None, SubColumn.VOICE)
+ panel = _panel(cell_kinds={key: VoiceKind.SAMPLE})
+
+ assert panel._cell_theme(key) == THEME_IDS[(SubColumn.VOICE, VoiceKind.SAMPLE)]
+
+ @pytest.mark.parametrize(
+ "subcolumn",
+ [SubColumn.TRANSPOSE, SubColumn.VOLUME],
+ ids=lambda subcolumn: subcolumn.value,
+ )
+ def test_the_other_slots_keep_their_own_color(self, subcolumn: SubColumn) -> None:
+ """A pitch and a volume mean the same whatever voice sounds them."""
+ panel = _panel(cell_kinds={(0, ChannelName.PULSE1, SubColumn.VOICE): VoiceKind.INSTRUMENT})
+
+ theme = panel._cell_theme((0, ChannelName.PULSE1, subcolumn))
+
+ assert theme == THEME_IDS[(subcolumn, None)]
+
+ def test_a_silenced_channel_dims_the_kind_it_names(self) -> None:
+ key = (0, ChannelName.TRIANGLE, SubColumn.VOICE)
+ panel = _panel(
+ cell_kinds={key: VoiceKind.INSTRUMENT},
+ muted=frozenset({ChannelName.TRIANGLE}),
+ )
+
+ assert panel._cell_theme(key) == MUTED_THEME_IDS[(SubColumn.VOICE, VoiceKind.INSTRUMENT)]
+
+ def test_the_sample_column_is_never_silenced(self) -> None:
+ """It speaks for every channel, so no one channel's mute reaches it."""
+ key = (0, None, SubColumn.VOICE)
+ panel = _panel(
+ cell_kinds={key: VoiceKind.SAMPLE},
+ muted=frozenset(ChannelName.items()),
+ )
+
+ assert panel._cell_theme(key) == THEME_IDS[(SubColumn.VOICE, VoiceKind.SAMPLE)]
+
+
+class TestWhichKindsTheGridReads:
+ def test_every_voice_slot_is_covered(self) -> None:
+ panel = _panel()
+
+ cell_kinds = panel._compute_cell_kinds(_view_model((_row(0), _row(1))))
+
+ assert set(cell_kinds) == {key for key in _keys() if key[2] is SubColumn.VOICE}
+
+ def test_a_named_channel_reports_its_voices_kind(self) -> None:
+ panel = _panel()
+
+ cell_kinds = panel._compute_cell_kinds(
+ _view_model((_row(0, named=(ChannelName.PULSE2, VoiceKind.INSTRUMENT)),)),
+ )
+
+ assert cell_kinds[(0, ChannelName.PULSE2, SubColumn.VOICE)] is VoiceKind.INSTRUMENT
+
+ def test_the_sample_column_reports_the_rows_own_kind(self) -> None:
+ panel = _panel()
+
+ cell_kinds = panel._compute_cell_kinds(
+ _view_model((_row(0, named=(ChannelName.PULSE1, VoiceKind.SAMPLE)),)),
+ )
+
+ assert cell_kinds[(0, None, SubColumn.VOICE)] is VoiceKind.SAMPLE
+
+ def test_an_instrument_leaves_the_sample_column_stating_nothing(self) -> None:
+ panel = _panel()
+
+ cell_kinds = panel._compute_cell_kinds(
+ _view_model((_row(0, named=(ChannelName.PULSE1, VoiceKind.INSTRUMENT)),)),
+ )
+
+ assert cell_kinds[(0, None, SubColumn.VOICE)] is None
+
+
+class TestWhatARefreshRebinds:
+ """A refresh re-themes the slots an edit changed, which is what keeps its cost with the edit."""
+
+ def test_a_changed_kind_rebinds_its_cell(self, bound: Dict[Sender, int]) -> None:
+ key = (0, ChannelName.PULSE1, SubColumn.VOICE)
+ panel = _panel()
+
+ panel._reconcile_cell_kinds({key: VoiceKind.SAMPLE})
+
+ assert bound[_cell_widget(key)] == THEME_IDS[(SubColumn.VOICE, VoiceKind.SAMPLE)]
+ assert panel._cell_kinds[key] is VoiceKind.SAMPLE
+
+ def test_an_unchanged_kind_leaves_its_cell_alone(self, bound: Dict[Sender, int]) -> None:
+ key = (0, ChannelName.PULSE1, SubColumn.VOICE)
+ panel = _panel(cell_kinds={key: VoiceKind.SAMPLE})
+
+ panel._reconcile_cell_kinds({key: VoiceKind.SAMPLE})
+
+ assert not bound
+
+ def test_only_the_changed_cells_are_rebound(self, bound: Dict[Sender, int]) -> None:
+ moved = (0, ChannelName.PULSE1, SubColumn.VOICE)
+ standing = (1, ChannelName.NOISE, SubColumn.VOICE)
+ panel = _panel(cell_kinds={moved: VoiceKind.SAMPLE, standing: VoiceKind.SAMPLE})
+
+ panel._reconcile_cell_kinds({moved: VoiceKind.INSTRUMENT, standing: VoiceKind.SAMPLE})
+
+ assert set(bound) == {_cell_widget(moved)}
+
+ def test_a_voice_taken_out_returns_its_cell_to_the_neutral_shade(self, bound: Dict[Sender, int]) -> None:
+ key = (0, ChannelName.PULSE1, SubColumn.VOICE)
+ panel = _panel(cell_kinds={key: VoiceKind.SAMPLE})
+
+ panel._reconcile_cell_kinds({key: None})
+
+ assert bound[_cell_widget(key)] == THEME_IDS[(SubColumn.VOICE, None)]
+
+
+class TestWhatAnEditShowsAtOnce:
+ """The number and the color are written together, so a typed voice reads whole in one frame."""
+
+ def test_a_placed_voice_takes_its_color_with_its_number(self, bound: Dict[Sender, int]) -> None:
+ panel = _panel()
+ key = (0, ChannelName.PULSE1, SubColumn.VOICE)
+
+ panel._show_voice(0, ChannelName.PULSE1, display_id(3), VoiceKind.INSTRUMENT)
+
+ assert panel._editable_cells.values[key] == display_id(3)
+ assert bound[_cell_widget(key)] == THEME_IDS[(SubColumn.VOICE, VoiceKind.INSTRUMENT)]
+
+ def test_a_cleared_slot_drops_its_number_and_its_color(self, bound: Dict[Sender, int]) -> None:
+ key = (0, ChannelName.PULSE1, SubColumn.VOICE)
+ panel = _panel(cell_kinds={key: VoiceKind.SAMPLE})
+ panel._editable_cells.values[key] = display_id(3)
+
+ panel._forget_voice(0, ChannelName.PULSE1)
+
+ assert key not in panel._editable_cells.values
+ assert bound[_cell_widget(key)] == THEME_IDS[(SubColumn.VOICE, None)]
diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_channels.py b/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_channels.py
index 535a10796..11982337f 100644
--- a/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_channels.py
+++ b/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_channels.py
@@ -4,13 +4,14 @@
import pytest
from sampletones_application.categories.manager import LanguageManager
-from sampletones_application.layout.tabs.sequencer.colors.channel import ChannelColors
+from sampletones_application.layout.general.colors.channel import ChannelColors
from sampletones_application.paths import LANG_EN
from sampletones_application.ui.elements.table.cells import EditableCells
from sampletones_application.ui.panels.sequencer import channels as channels_module
-from sampletones_application.ui.panels.sequencer import tracker as tracker_module
from sampletones_application.ui.panels.sequencer.columns import tracker_table_column
-from sampletones_application.ui.panels.sequencer.tracker import GUISequencerTrackerPanel
+from sampletones_application.ui.panels.sequencer.tracker import panel as tracker_module
+from sampletones_application.ui.panels.sequencer.tracker.callbacks import ThemeKey
+from sampletones_application.ui.panels.sequencer.tracker.panel import GUISequencerTrackerPanel
from sampletones_application.utils.gui.keyboard.modifiers import (
CTRL,
NO_MODIFIERS,
@@ -21,8 +22,9 @@
SequencerChannelsViewModel,
)
from sampletones_application.view_model.sequencer.subcolumn import SubColumn
-from sampletones_core.constants.enums import GeneratorName
+from sampletones_core.constants.enums import ChannelName
from sampletones_shared.types.application import ColorRGBA, Sender
+from tests.unit.sampletones_application.ui.panels.sequencer.test_tracker_cell_themes import _themes
HEADER_WIDGET_ID = 7100
"""A stand-in for the header selectable id DearPyGui passes as the callback's sender."""
@@ -41,15 +43,15 @@
HEADER_THEME = 1
MUTED_HEADER_THEME = 2
-SUBCOLUMN_THEMES: Dict[SubColumn, int] = {
- SubColumn.INSTRUMENT: 10,
- SubColumn.TRANSPOSE: 11,
- SubColumn.VOLUME: 12,
+SUBCOLUMN_THEMES: Dict[ThemeKey, int] = {
+ (SubColumn.VOICE, None): 10,
+ (SubColumn.TRANSPOSE, None): 11,
+ (SubColumn.VOLUME, None): 12,
}
-MUTED_SUBCOLUMN_THEMES: Dict[SubColumn, int] = {
- SubColumn.INSTRUMENT: 20,
- SubColumn.TRANSPOSE: 21,
- SubColumn.VOLUME: 22,
+MUTED_SUBCOLUMN_THEMES: Dict[ThemeKey, int] = {
+ (SubColumn.VOICE, None): 20,
+ (SubColumn.TRANSPOSE, None): 21,
+ (SubColumn.VOLUME, None): 22,
}
@@ -75,28 +77,27 @@ def set_value(self, item: Sender, value: bool) -> None:
self.released.append(item)
-HEADER_COLUMNS: Tuple[Optional[GeneratorName], ...] = (None, *GeneratorName.items())
+HEADER_COLUMNS: Tuple[Optional[ChannelName], ...] = (None, *ChannelName.items())
-def _header_widget(generator: Optional[GeneratorName]) -> int:
+def _header_widget(channel: Optional[ChannelName]) -> int:
"""A stable stand-in widget id per header column."""
- return 100 + HEADER_COLUMNS.index(generator)
+ return 100 + HEADER_COLUMNS.index(channel)
-def _cell_widget(generator: GeneratorName, row_index: int, subcolumn: SubColumn) -> int:
- return 1000 + 100 * GeneratorName.items().index(generator) + 10 * row_index + list(SubColumn).index(subcolumn)
+def _cell_widget(channel: ChannelName, row_index: int, subcolumn: SubColumn) -> int:
+ return 1000 + 100 * ChannelName.items().index(channel) + 10 * row_index + list(SubColumn).index(subcolumn)
-def _panel(muted: FrozenSet[GeneratorName]) -> GUISequencerTrackerPanel:
+def _panel(muted: FrozenSet[ChannelName]) -> GUISequencerTrackerPanel:
"""Builds a panel around the state the channel cues read, with no DearPyGui context.
- The cues touch the layout colours, the theme ids, the header widgets, and the cell
+ The cues touch the layout colors, the theme ids, the header widgets, and the cell
registry, so those are wired directly and the rest of the panel is left out.
"""
panel = GUISequencerTrackerPanel.__new__(GUISequencerTrackerPanel)
panel._layout = SimpleNamespace(
colors=SimpleNamespace(
- channels=CHANNEL_COLORS,
muted=SimpleNamespace(background=LiteralColor(MUTED_BACKGROUND)),
),
tracker=SimpleNamespace(
@@ -104,21 +105,25 @@ def _panel(muted: FrozenSet[GeneratorName]) -> GUISequencerTrackerPanel:
muted_text_fraction=MUTED_TEXT_FRACTION,
),
)
+ panel._channel_colors = CHANNEL_COLORS
panel._current_channels = SequencerChannelsViewModel(muted=muted)
panel._current_row_count = ROW_COUNT
- panel._header_theme = HEADER_THEME
- panel._muted_header_theme = MUTED_HEADER_THEME
- panel._subcolumn_themes = dict(SUBCOLUMN_THEMES)
- panel._muted_subcolumn_themes = dict(MUTED_SUBCOLUMN_THEMES)
- panel._header_columns = {_header_widget(generator): generator for generator in HEADER_COLUMNS}
+ panel._cell_kinds = {}
+ panel._themes = _themes(
+ SUBCOLUMN_THEMES,
+ MUTED_SUBCOLUMN_THEMES,
+ header=HEADER_THEME,
+ muted_header=MUTED_HEADER_THEME,
+ )
+ panel._header_columns = {_header_widget(channel): channel for channel in HEADER_COLUMNS}
panel._create_channel_switch(LanguageManager(LANG_EN))
panel._editable_cells = EditableCells()
- for generator in GeneratorName.items():
+ for channel in ChannelName.items():
for row_index in range(ROW_COUNT):
for subcolumn in SubColumn:
panel._editable_cells.register(
- (row_index, generator, subcolumn),
- _cell_widget(generator, row_index, subcolumn),
+ (row_index, channel, subcolumn),
+ _cell_widget(channel, row_index, subcolumn),
)
return panel
@@ -146,12 +151,12 @@ def test_plain_click_toggles_the_clicked_channel(
) -> None:
panel = _panel(frozenset())
_hold(monkeypatch, NO_MODIFIERS)
- toggled: List[GeneratorName] = []
+ toggled: List[ChannelName] = []
panel.on_channel_mute_toggled = toggled.append
- panel._on_header_clicked(HEADER_WIDGET_ID, True, GeneratorName.TRIANGLE)
+ panel._on_header_clicked(HEADER_WIDGET_ID, True, ChannelName.TRIANGLE)
- assert toggled == [GeneratorName.TRIANGLE]
+ assert toggled == [ChannelName.TRIANGLE]
def test_ctrl_click_solos_the_clicked_channel(
self,
@@ -160,13 +165,13 @@ def test_ctrl_click_solos_the_clicked_channel(
) -> None:
panel = _panel(frozenset())
_hold(monkeypatch, CTRL)
- soloed: List[GeneratorName] = []
+ soloed: List[ChannelName] = []
panel.on_channel_soloed = soloed.append
panel.on_channel_mute_toggled = lambda _: pytest.fail("Ctrl+click must not toggle")
- panel._on_header_clicked(HEADER_WIDGET_ID, True, GeneratorName.NOISE)
+ panel._on_header_clicked(HEADER_WIDGET_ID, True, ChannelName.NOISE)
- assert soloed == [GeneratorName.NOISE]
+ assert soloed == [ChannelName.NOISE]
def test_sample_header_switches_every_channel(
self,
@@ -199,22 +204,22 @@ def test_ctrl_on_the_sample_header_still_switches_every_channel(
assert switches == [None]
@pytest.mark.parametrize(
- "generator",
+ "channel",
HEADER_COLUMNS,
- ids=lambda generator: "sample" if generator is None else generator.value,
+ ids=lambda channel: "sample" if channel is None else channel.value,
)
def test_every_header_click_releases_the_selectable(
self,
recorder: _DearPyGuiRecorder,
monkeypatch: pytest.MonkeyPatch,
- generator: Optional[GeneratorName],
+ channel: Optional[ChannelName],
) -> None:
panel = _panel(frozenset())
_hold(monkeypatch, NO_MODIFIERS)
panel.on_channels_toggled = lambda: None
panel.on_channel_mute_toggled = lambda _: None
- panel._on_header_clicked(HEADER_WIDGET_ID, True, generator)
+ panel._on_header_clicked(HEADER_WIDGET_ID, True, channel)
assert recorder.released == [HEADER_WIDGET_ID]
@@ -225,58 +230,58 @@ def test_audible_channel_keeps_its_identity_tint(self, recorder: _DearPyGuiRecor
panel._apply_channel_cues()
- column = tracker_table_column(GeneratorName.PULSE1)
+ column = tracker_table_column(ChannelName.PULSE1)
assert recorder.column_tints[column] == (240, 146, 86, 128)
def test_muted_channel_takes_the_neutral_wash(self, recorder: _DearPyGuiRecorder) -> None:
- panel = _panel(frozenset({GeneratorName.PULSE1}))
+ panel = _panel(frozenset({ChannelName.PULSE1}))
panel._apply_channel_cues()
- column = tracker_table_column(GeneratorName.PULSE1)
+ column = tracker_table_column(ChannelName.PULSE1)
assert recorder.column_tints[column] == MUTED_BACKGROUND
def test_the_other_channels_keep_their_tint_while_one_is_muted(self, recorder: _DearPyGuiRecorder) -> None:
- panel = _panel(frozenset({GeneratorName.TRIANGLE}))
+ panel = _panel(frozenset({ChannelName.TRIANGLE}))
panel._apply_channel_cues()
washed = {
- generator
- for generator in GeneratorName.items()
- if recorder.column_tints[tracker_table_column(generator)] == MUTED_BACKGROUND
+ channel
+ for channel in ChannelName.items()
+ if recorder.column_tints[tracker_table_column(channel)] == MUTED_BACKGROUND
}
- assert washed == {GeneratorName.TRIANGLE}
+ assert washed == {ChannelName.TRIANGLE}
def test_every_channel_column_is_painted(self, recorder: _DearPyGuiRecorder) -> None:
panel = _panel(frozenset())
panel._apply_channel_cues()
- expected = {tracker_table_column(generator) for generator in GeneratorName.items()}
+ expected = {tracker_table_column(channel) for channel in ChannelName.items()}
assert expected <= set(recorder.column_tints)
class TestHeaderLabelShade:
def test_muted_channel_label_takes_the_muted_shade(self, recorder: _DearPyGuiRecorder) -> None:
- panel = _panel(frozenset({GeneratorName.PULSE2}))
+ panel = _panel(frozenset({ChannelName.PULSE2}))
panel._apply_channel_cues()
- assert recorder.bound_themes[_header_widget(GeneratorName.PULSE2)] == MUTED_HEADER_THEME
+ assert recorder.bound_themes[_header_widget(ChannelName.PULSE2)] == MUTED_HEADER_THEME
def test_audible_channel_label_keeps_the_plain_shade(self, recorder: _DearPyGuiRecorder) -> None:
- panel = _panel(frozenset({GeneratorName.PULSE2}))
+ panel = _panel(frozenset({ChannelName.PULSE2}))
panel._apply_channel_cues()
- assert recorder.bound_themes[_header_widget(GeneratorName.NOISE)] == HEADER_THEME
+ assert recorder.bound_themes[_header_widget(ChannelName.NOISE)] == HEADER_THEME
def test_sample_label_keeps_the_plain_shade_with_every_channel_muted(
self,
recorder: _DearPyGuiRecorder,
) -> None:
- panel = _panel(frozenset(GeneratorName.items()))
+ panel = _panel(frozenset(ChannelName.items()))
panel._apply_channel_cues()
@@ -285,46 +290,46 @@ def test_sample_label_keeps_the_plain_shade_with_every_channel_muted(
class TestCellTextShade:
def test_muted_channel_cells_take_the_dimmed_theme(self, recorder: _DearPyGuiRecorder) -> None:
- panel = _panel(frozenset({GeneratorName.TRIANGLE}))
+ panel = _panel(frozenset({ChannelName.TRIANGLE}))
panel._apply_channel_cues()
bound = {
- recorder.bound_themes[_cell_widget(GeneratorName.TRIANGLE, row_index, subcolumn)]
+ recorder.bound_themes[_cell_widget(ChannelName.TRIANGLE, row_index, subcolumn)]
for row_index in range(ROW_COUNT)
for subcolumn in SubColumn
}
assert bound == set(MUTED_SUBCOLUMN_THEMES.values())
def test_audible_channel_cells_take_the_full_theme(self, recorder: _DearPyGuiRecorder) -> None:
- panel = _panel(frozenset({GeneratorName.TRIANGLE}))
+ panel = _panel(frozenset({ChannelName.TRIANGLE}))
panel._apply_channel_cues()
bound = {
- recorder.bound_themes[_cell_widget(GeneratorName.PULSE1, row_index, subcolumn)]
+ recorder.bound_themes[_cell_widget(ChannelName.PULSE1, row_index, subcolumn)]
for row_index in range(ROW_COUNT)
for subcolumn in SubColumn
}
assert bound == set(SUBCOLUMN_THEMES.values())
def test_each_subcolumn_keeps_its_own_hue_when_dimmed(self, recorder: _DearPyGuiRecorder) -> None:
- panel = _panel(frozenset({GeneratorName.NOISE}))
+ panel = _panel(frozenset({ChannelName.NOISE}))
panel._apply_channel_cues()
for subcolumn in SubColumn:
- widget = _cell_widget(GeneratorName.NOISE, 0, subcolumn)
- assert recorder.bound_themes[widget] == MUTED_SUBCOLUMN_THEMES[subcolumn]
+ widget = _cell_widget(ChannelName.NOISE, 0, subcolumn)
+ assert recorder.bound_themes[widget] == MUTED_SUBCOLUMN_THEMES[(subcolumn, None)]
def test_unmuting_restores_the_full_theme(self, recorder: _DearPyGuiRecorder) -> None:
- panel = _panel(frozenset({GeneratorName.NOISE}))
+ panel = _panel(frozenset({ChannelName.NOISE}))
panel._apply_channel_cues()
panel.update_channels(SequencerChannelsViewModel(muted=frozenset()))
- widget = _cell_widget(GeneratorName.NOISE, 1, SubColumn.VOLUME)
- assert recorder.bound_themes[widget] == SUBCOLUMN_THEMES[SubColumn.VOLUME]
+ widget = _cell_widget(ChannelName.NOISE, 1, SubColumn.VOLUME)
+ assert recorder.bound_themes[widget] == SUBCOLUMN_THEMES[(SubColumn.VOLUME, None)]
class TestCuesAwaitTheTable:
@@ -340,11 +345,11 @@ def test_the_model_is_kept_while_the_table_is_absent(self, monkeypatch: pytest.M
monkeypatch.setattr(tracker_module.dpg, "bind_item_theme", instance.bind_item_theme)
panel = _panel(frozenset())
- panel.update_channels(SequencerChannelsViewModel(muted=frozenset({GeneratorName.PULSE1})))
+ panel.update_channels(SequencerChannelsViewModel(muted=frozenset({ChannelName.PULSE1})))
assert not instance.column_tints
assert not instance.bound_themes
- assert panel._is_muted(GeneratorName.PULSE1)
+ assert panel._is_muted(ChannelName.PULSE1)
class TestMuteStateReading:
@@ -352,19 +357,19 @@ class TestMuteStateReading:
"muted, expected",
[
(frozenset(), set()),
- (frozenset({GeneratorName.PULSE1}), {GeneratorName.PULSE1}),
- (frozenset(GeneratorName.items()), set(GeneratorName.items())),
+ (frozenset({ChannelName.PULSE1}), {ChannelName.PULSE1}),
+ (frozenset(ChannelName.items()), set(ChannelName.items())),
],
ids=["full mix", "one silenced", "every channel silenced"],
)
def test_is_muted_follows_the_pushed_model(
self,
- muted: FrozenSet[GeneratorName],
- expected: FrozenSet[GeneratorName],
+ muted: FrozenSet[ChannelName],
+ expected: FrozenSet[ChannelName],
) -> None:
panel = _panel(muted)
- reported = {generator for generator in GeneratorName.items() if panel._is_muted(generator)}
+ reported = {channel for channel in ChannelName.items() if panel._is_muted(channel)}
assert reported == expected
@@ -372,25 +377,25 @@ def test_no_channel_reads_as_muted_before_a_model_arrives(self) -> None:
panel = _panel(frozenset())
panel._current_channels = None
- assert not any(panel._is_muted(generator) for generator in GeneratorName.items())
+ assert not any(panel._is_muted(channel) for channel in ChannelName.items())
-class TestChannelTintColour:
+class TestChannelTintColor:
@pytest.mark.parametrize(
- "generator, expected",
+ "channel, expected",
[
- (GeneratorName.PULSE1, (240, 146, 86, 128)),
- (GeneratorName.PULSE2, (242, 209, 95, 128)),
- (GeneratorName.TRIANGLE, (140, 193, 237, 128)),
- (GeneratorName.NOISE, (187, 184, 194, 128)),
+ (ChannelName.PULSE1, (240, 146, 86, 128)),
+ (ChannelName.PULSE2, (242, 209, 95, 128)),
+ (ChannelName.TRIANGLE, (140, 193, 237, 128)),
+ (ChannelName.NOISE, (187, 184, 194, 128)),
],
- ids=lambda value: value.value if isinstance(value, GeneratorName) else "",
+ ids=lambda value: value.value if isinstance(value, ChannelName) else "",
)
- def test_audible_tint_is_the_identity_colour_at_the_configured_fraction(
+ def test_audible_tint_is_the_identity_color_at_the_configured_fraction(
self,
- generator: GeneratorName,
+ channel: ChannelName,
expected: Tuple[int, int, int, int],
) -> None:
panel = _panel(frozenset())
- assert panel._channel_column_tint(generator) == expected
+ assert panel._channel_column_tint(channel) == expected
diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_context_menu.py b/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_context_menu.py
deleted file mode 100644
index 582850156..000000000
--- a/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_context_menu.py
+++ /dev/null
@@ -1,153 +0,0 @@
-import contextlib
-from typing import Any, Iterator, List, Tuple
-
-import pytest
-
-from sampletones_application.ui.panels.sequencer import tracker as tracker_module
-from sampletones_application.ui.panels.sequencer.input.target import TrackerTarget
-from sampletones_application.ui.panels.sequencer.input.tracker import TrackerCursor, TrackerInputState
-from sampletones_application.view_model.sequencer.region import TrackerRegion
-from sampletones_application.view_model.sequencer.samples import (
- SampleEntryViewModel,
- SequencerSamplesViewModel,
-)
-from sampletones_application.view_model.sequencer.subcolumn import SubColumn
-from sampletones_core.constants.enums import GeneratorName
-from sampletones_shared.constants.music import OCTAVE_SEMITONES, SEMITONE_STEP
-from tests.suite.shortcuts import shipped_source
-
-SENDER_WIDGET_ID = 6099
-"""A stand-in for the menu-item widget id DearPyGui passes as the callback's first
-positional argument. The original bug let this id overwrite the step payload."""
-
-
-_CONTEXT_LABELS = (
- "_lbl_context_set_instrument",
- "_lbl_context_no_samples",
-)
-
-
-def _panel() -> tracker_module.GUISequencerTrackerPanel:
- """Builds a panel without its DearPyGui-dependent constructor.
-
- The menu-dispatch methods touch only their hook attributes, the context
- labels, the keys each item prints, and ``CallbackMixin.call``, so a fully
- wired GUI context is unnecessary here. Labels carry no behaviour, so any
- placeholder text serves.
- """
- panel = tracker_module.GUISequencerTrackerPanel.__new__(tracker_module.GUISequencerTrackerPanel)
- for label in _CONTEXT_LABELS:
- setattr(panel, label, "")
-
- panel._lbl_adjust = {
- element: ""
- for element, _, _ in (
- *tracker_module.TRANSPOSE_ACTIONS,
- *tracker_module.VOLUME_ACTIONS,
- )
- }
- panel._shortcuts = shipped_source()
- return panel
-
-
-class _MenuItemRecorder:
- """Captures the ``user_data``/``callback`` pairs the builders register."""
-
- def __init__(self) -> None:
- self.items: List[Tuple[Any, Any]] = []
-
- def add_menu_item(self, **kwargs: Any) -> int:
- if "callback" in kwargs and "user_data" in kwargs:
- self.items.append((kwargs["user_data"], kwargs["callback"]))
- return 0
-
- def dispatch_as_dpg(self) -> None:
- """Fires each recorded callback the way DearPyGui does: sender first."""
- for user_data, callback in self.items:
- callback(SENDER_WIDGET_ID, None, user_data)
-
-
-@pytest.fixture
-def recorder(monkeypatch: pytest.MonkeyPatch) -> _MenuItemRecorder:
- instance = _MenuItemRecorder()
- monkeypatch.setattr(tracker_module.dpg, "add_menu_item", instance.add_menu_item)
-
- @contextlib.contextmanager
- def _menu(**kwargs: Any) -> Iterator[None]:
- yield
-
- monkeypatch.setattr(tracker_module.dpg, "menu", _menu)
- return instance
-
-
-def _cell(row: int, generator: GeneratorName) -> TrackerCursor:
- """The cell a menu was raised on, which the items carry as their payload."""
- return TrackerCursor(row, generator, SubColumn.INSTRUMENT)
-
-
-def _target(row: int, generator: GeneratorName) -> TrackerTarget:
- """The cell a menu was raised on, paired with the block of that cell alone."""
- cell = _cell(row, generator)
- return TrackerTarget(cell=cell, region=TrackerInputState().region_at(cell))
-
-
-class TestMenuDispatchPreservesPayload:
- def test_transpose_items_pass_the_configured_step(self, recorder: _MenuItemRecorder) -> None:
- panel = _panel()
- deltas: List[int] = []
- panel.on_adjust_transpose = lambda region, delta: deltas.append(delta)
-
- panel._add_transpose_items(_target(2, GeneratorName.PULSE1))
- recorder.dispatch_as_dpg()
-
- assert deltas == [
- SEMITONE_STEP,
- -SEMITONE_STEP,
- OCTAVE_SEMITONES,
- -OCTAVE_SEMITONES,
- ]
-
- def test_volume_items_pass_the_configured_step(self, recorder: _MenuItemRecorder) -> None:
- panel = _panel()
- deltas: List[int] = []
- panel.on_adjust_volume = lambda region, delta: deltas.append(delta)
-
- panel._add_volume_items(_target(2, GeneratorName.PULSE1))
- recorder.dispatch_as_dpg()
-
- assert deltas == [
- tracker_module.VOLUME_FINE_STEP,
- -tracker_module.VOLUME_FINE_STEP,
- tracker_module.VOLUME_COARSE_STEP,
- -tracker_module.VOLUME_COARSE_STEP,
- ]
-
- def test_adjust_carries_the_block_the_menu_was_raised_on(self, recorder: _MenuItemRecorder) -> None:
- panel = _panel()
- calls: List[Tuple[TrackerRegion, int]] = []
- panel.on_adjust_transpose = lambda region, delta: calls.append((region, delta))
- target = _target(7, GeneratorName.TRIANGLE)
-
- panel._add_transpose_items(target)
- recorder.dispatch_as_dpg()
-
- assert calls[0] == (target.region, SEMITONE_STEP)
-
- def test_instrument_items_pass_the_sample_id(self, recorder: _MenuItemRecorder) -> None:
- panel = _panel()
- panel._current_samples = SequencerSamplesViewModel(
- samples=(
- SampleEntryViewModel(
- sample_id="lead-id",
- name="lead",
- loop=False,
- ),
- ),
- )
- chosen: List[str] = []
- panel.on_set_row = lambda row, generator, sample_id, transpose, volume: chosen.append(sample_id)
-
- panel._add_instrument_submenu(_cell(0, GeneratorName.PULSE2))
- recorder.dispatch_as_dpg()
-
- assert chosen == ["lead-id"]
diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_navigation.py b/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_navigation.py
index 2bfec6503..af1dfee49 100644
--- a/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_navigation.py
+++ b/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_navigation.py
@@ -4,9 +4,9 @@
import pytest
-from sampletones_application.ui.panels.sequencer import tracker
from sampletones_application.ui.panels.sequencer.input.tracker import TrackerCursor, TrackerInputState
-from sampletones_application.ui.panels.sequencer.tracker import GUISequencerTrackerPanel
+from sampletones_application.ui.panels.sequencer.tracker import panel as tracker
+from sampletones_application.ui.panels.sequencer.tracker.panel import GUISequencerTrackerPanel
from sampletones_application.utils.gui.keyboard import KeyEvent
from sampletones_application.utils.gui.keyboard.combination import KeyCombination
from sampletones_application.utils.gui.keyboard.keys import KEY_PAGE_DOWN, KEY_PAGE_UP
@@ -32,7 +32,7 @@ def _panel() -> GUISequencerTrackerPanel:
panel = GUISequencerTrackerPanel.__new__(GUISequencerTrackerPanel)
panel._shortcuts = shipped_source()
panel._input_state = TrackerInputState(
- cursor=TrackerCursor(CURSOR_ROW, None, SubColumn.INSTRUMENT),
+ cursor=TrackerCursor(CURSOR_ROW, None, SubColumn.VOICE),
pending="",
)
panel._layout = SimpleNamespace(tracker=SimpleNamespace(page_size=PAGE_SIZE))
diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_piano.py b/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_piano.py
new file mode 100644
index 000000000..1001b6a3b
--- /dev/null
+++ b/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_piano.py
@@ -0,0 +1,122 @@
+from dataclasses import dataclass, field
+from typing import Final, List, Optional, Tuple
+
+import pytest
+
+from sampletones_application.constants.tracker import DEFAULT_OCTAVE
+from sampletones_application.ui.panels.sequencer.input.tracker import (
+ TrackerCursor,
+ TrackerInputState,
+)
+from sampletones_application.ui.panels.sequencer.tracker.panel import GUISequencerTrackerPanel
+from sampletones_application.utils.gui.keyboard.combination import KeyCombination
+from sampletones_application.utils.gui.keyboard.event import KeyEvent
+from sampletones_application.view_model.sequencer.subcolumn import SubColumn
+from sampletones_core.constants.enums import ChannelName
+from sampletones_shared.constants.music import OCTAVE_OFFSET, OCTAVE_SEMITONES
+
+ROW: Final[int] = 3
+ROW_COUNT: Final[int] = 64
+
+Typed = Tuple[int, ChannelName, int]
+
+
+@dataclass
+class PianoFixture:
+ panel: GUISequencerTrackerPanel
+ typed: List[Typed] = field(default_factory=list)
+ states: List[TrackerInputState] = field(default_factory=list)
+
+
+def _panel(
+ monkeypatch: pytest.MonkeyPatch,
+ channel: Optional[ChannelName],
+ subcolumn: SubColumn = SubColumn.TRANSPOSE,
+) -> PianoFixture:
+ """A tracker panel carrying only the state the note path reads."""
+ panel = GUISequencerTrackerPanel.__new__(GUISequencerTrackerPanel)
+ panel._octave = DEFAULT_OCTAVE
+ panel._current_row_count = ROW_COUNT
+ panel._input_state = TrackerInputState(cursor=TrackerCursor(ROW, channel, subcolumn))
+
+ fixture = PianoFixture(panel=panel)
+ panel.on_note_typed = lambda row, target, pitch: fixture.typed.append((row, target, pitch))
+ monkeypatch.setattr(panel, "_apply_state", fixture.states.append)
+ return fixture
+
+
+def _press(text: str) -> KeyEvent:
+ combination = KeyCombination.parse(text)
+ return KeyEvent(key=combination.key, modifiers=combination.modifiers)
+
+
+def _pitch(octave: int, semitone: int) -> int:
+ return (octave + OCTAVE_OFFSET) * OCTAVE_SEMITONES + semitone
+
+
+class TestANoteKeyWritesTheNoteItNames:
+ def test_the_bottom_row_opens_at_the_octave_in_force(self, monkeypatch: pytest.MonkeyPatch) -> None:
+ fixture = _panel(monkeypatch, ChannelName.PULSE1)
+
+ assert fixture.panel._type_note(_press("Z")) is True
+ assert fixture.typed == [(ROW, ChannelName.PULSE1, _pitch(DEFAULT_OCTAVE, 0))]
+
+ def test_the_top_row_opens_an_octave_above_it(self, monkeypatch: pytest.MonkeyPatch) -> None:
+ fixture = _panel(monkeypatch, ChannelName.PULSE1)
+
+ fixture.panel._type_note(_press("Q"))
+
+ assert fixture.typed == [(ROW, ChannelName.PULSE1, _pitch(DEFAULT_OCTAVE + 1, 0))]
+
+ def test_a_black_key_lands_a_semitone_above_the_white_one_below_it(
+ self,
+ monkeypatch: pytest.MonkeyPatch,
+ ) -> None:
+ fixture = _panel(monkeypatch, ChannelName.PULSE1)
+
+ fixture.panel._type_note(_press("S"))
+
+ assert fixture.typed == [(ROW, ChannelName.PULSE1, _pitch(DEFAULT_OCTAVE, 1))]
+
+ def test_the_octave_in_force_moves_the_note(self, monkeypatch: pytest.MonkeyPatch) -> None:
+ fixture = _panel(monkeypatch, ChannelName.PULSE1)
+ fixture.panel._octave = DEFAULT_OCTAVE - 1
+
+ fixture.panel._type_note(_press("Z"))
+
+ assert fixture.typed == [(ROW, ChannelName.PULSE1, _pitch(DEFAULT_OCTAVE - 1, 0))]
+
+ def test_a_typed_note_steps_onto_the_next_row(self, monkeypatch: pytest.MonkeyPatch) -> None:
+ fixture = _panel(monkeypatch, ChannelName.PULSE1)
+
+ fixture.panel._type_note(_press("Z"))
+
+ assert fixture.states[-1].cursor is not None
+ assert fixture.states[-1].cursor.row == ROW + 1
+
+
+class TestWhereTheNoteKeysStayOut:
+ def test_the_sample_column_keeps_its_own_face(self, monkeypatch: pytest.MonkeyPatch) -> None:
+ """The column speaks for a whole sample, whose channels rest at pitches of their own."""
+ fixture = _panel(monkeypatch, None)
+
+ assert fixture.panel._type_note(_press("Z")) is False
+ assert fixture.typed == []
+
+ def test_the_noise_channel_keeps_its_hex_entry(self, monkeypatch: pytest.MonkeyPatch) -> None:
+ fixture = _panel(monkeypatch, ChannelName.NOISE)
+
+ assert fixture.panel._type_note(_press("Z")) is False
+ assert fixture.typed == []
+
+ def test_another_subcolumn_keeps_its_own_keys(self, monkeypatch: pytest.MonkeyPatch) -> None:
+ fixture = _panel(monkeypatch, ChannelName.PULSE1, SubColumn.VOLUME)
+
+ assert fixture.panel._type_note(_press("Z")) is False
+ assert fixture.typed == []
+
+ def test_a_key_no_note_stands_on_is_left_alone(self, monkeypatch: pytest.MonkeyPatch) -> None:
+ fixture = _panel(monkeypatch, ChannelName.PULSE1)
+
+ assert fixture.panel._type_note(_press("K")) is False
+ assert fixture.typed == []
diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_play_shortcut.py b/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_play_shortcut.py
index 9a6c38c97..de2ac3390 100644
--- a/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_play_shortcut.py
+++ b/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_play_shortcut.py
@@ -3,7 +3,7 @@
import dearpygui.dearpygui as dpg
from sampletones_application.ui.panels.sequencer.input.tracker import TrackerCursor, TrackerInputState
-from sampletones_application.ui.panels.sequencer.tracker import GUISequencerTrackerPanel
+from sampletones_application.ui.panels.sequencer.tracker.panel import GUISequencerTrackerPanel
from sampletones_application.utils.gui.keyboard import KeyEvent
from sampletones_application.utils.gui.keyboard.modifiers import CTRL, CTRL_SHIFT
from sampletones_application.view_model.sequencer.subcolumn import SubColumn
@@ -26,7 +26,7 @@ class TestGridPlayFromHere:
def test_ctrl_shift_space_plays_from_the_cursor_row(self) -> None:
rows: List[int] = []
- panel = _panel(TrackerCursor(5, None, SubColumn.INSTRUMENT))
+ panel = _panel(TrackerCursor(5, None, SubColumn.VOICE))
panel.on_play_from_row = rows.append
assert panel._on_key_pressed(_play_from_here()) is True
@@ -39,7 +39,7 @@ def test_ctrl_shift_space_yields_without_a_cursor(self) -> None:
def test_ctrl_space_yields_to_the_global_shortcut(self) -> None:
played: List[int] = []
- panel = _panel(TrackerCursor(5, None, SubColumn.INSTRUMENT))
+ panel = _panel(TrackerCursor(5, None, SubColumn.VOICE))
panel.on_play_from_row = played.append
result = panel._on_key_pressed(KeyEvent(key=dpg.mvKey_Spacebar, modifiers=CTRL))
diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_rows.py b/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_rows.py
index 6ae66a05d..c9bf4709d 100644
--- a/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_rows.py
+++ b/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_rows.py
@@ -3,20 +3,20 @@
import pytest
-from sampletones_application.ui.panels.sequencer import tracker as tracker_module
from sampletones_application.ui.panels.sequencer.columns import (
HEADER_TABLE_ROW,
tracker_table_column,
tracker_table_row,
)
from sampletones_application.ui.panels.sequencer.input.tracker import TrackerCursor, TrackerInputState
-from sampletones_application.ui.panels.sequencer.tracker import GUISequencerTrackerPanel
+from sampletones_application.ui.panels.sequencer.tracker import panel as tracker_module
+from sampletones_application.ui.panels.sequencer.tracker.panel import GUISequencerTrackerPanel
from sampletones_application.utils.palette.colors.written import LiteralColor
from sampletones_application.view_model.sequencer.settings import (
SequencerSettingsViewModel,
)
from sampletones_application.view_model.sequencer.subcolumn import SubColumn
-from sampletones_core.constants.enums import GeneratorName
+from sampletones_core.constants.enums import ChannelName
from sampletones_core.project.song_position import SongPosition
from sampletones_shared.types.application import ColorRGBA
@@ -78,7 +78,7 @@ def _settings(
first_highlight: int = ROWS_PER_BEAT,
second_highlight: int = ROWS_PER_BAR,
) -> SequencerSettingsViewModel:
- """The module settings the panel reads its metre out of."""
+ """The module settings the panel reads its meter out of."""
return SequencerSettingsViewModel(
nes_frequency=60,
tempo=150,
@@ -124,11 +124,11 @@ def _playhead(frame_index: int, row_index: int) -> SongPosition:
def _place_cursor(
panel: GUISequencerTrackerPanel,
row_index: int,
- generator: Optional[GeneratorName],
+ channel: Optional[ChannelName],
) -> None:
"""Puts the cursor where the panel's own state keeps it, the way an edit action does."""
panel._input_state = TrackerInputState(
- cursor=TrackerCursor(row_index, generator, SubColumn.INSTRUMENT),
+ cursor=TrackerCursor(row_index, channel, SubColumn.VOICE),
pending="",
)
@@ -190,7 +190,7 @@ def test_the_header_row_takes_no_row_background(self, recorder: _TableRecorder)
assert HEADER_TABLE_ROW not in recorder.highlighted_rows
assert HEADER_TABLE_ROW not in recorder.unhighlighted_rows
- def test_an_edited_metre_retints_the_rows_at_once(self, recorder: _TableRecorder) -> None:
+ def test_an_edited_meter_retints_the_rows_at_once(self, recorder: _TableRecorder) -> None:
"""The highlights are the project's, so a change to them reaches the grid as a repaint."""
panel = _panel()
panel._apply_row_backgrounds()
@@ -221,9 +221,9 @@ def test_the_cursor_lands_on_the_mapped_table_row(
row_index: int,
) -> None:
panel = _panel()
- _place_cursor(panel, row_index, GeneratorName.TRIANGLE)
+ _place_cursor(panel, row_index, ChannelName.TRIANGLE)
- panel._apply_cell_highlight(row_index, GeneratorName.TRIANGLE)
+ panel._apply_cell_highlight(row_index, ChannelName.TRIANGLE)
assert recorder.highlighted_rows == {tracker_table_row(row_index): CURSOR_ROW}
@@ -234,20 +234,20 @@ def test_the_cursor_on_a_group_row_carries_both_shades(
row_index: int,
) -> None:
panel = _panel()
- _place_cursor(panel, row_index, GeneratorName.TRIANGLE)
+ _place_cursor(panel, row_index, ChannelName.TRIANGLE)
- panel._apply_cell_highlight(row_index, GeneratorName.TRIANGLE)
+ panel._apply_cell_highlight(row_index, ChannelName.TRIANGLE)
painted = recorder.highlighted_rows[tracker_table_row(row_index)]
assert painted[3] > CURSOR_ROW[3]
def test_the_cursor_cell_lands_on_the_mapped_row_and_column(self, recorder: _TableRecorder) -> None:
panel = _panel()
- _place_cursor(panel, 2, GeneratorName.NOISE)
+ _place_cursor(panel, 2, ChannelName.NOISE)
- panel._apply_cell_highlight(2, GeneratorName.NOISE)
+ panel._apply_cell_highlight(2, ChannelName.NOISE)
- key = (tracker_table_row(2), tracker_table_column(GeneratorName.NOISE))
+ key = (tracker_table_row(2), tracker_table_column(ChannelName.NOISE))
assert recorder.highlighted_cells == {key: CELL_CURSOR}
def test_no_cursor_ever_paints_the_header_row(self, recorder: _TableRecorder) -> None:
@@ -345,7 +345,7 @@ def test_the_playhead_over_a_group_row_carries_both_shades(
def test_the_playhead_outranks_the_cursor_on_the_same_row(self, recorder: _TableRecorder) -> None:
panel = _panel()
- _place_cursor(panel, 1, GeneratorName.PULSE1)
+ _place_cursor(panel, 1, ChannelName.PULSE1)
panel.set_playing_position(_playhead(SHOWN_FRAME, 1))
@@ -421,7 +421,7 @@ def test_returning_to_the_sounding_frame_marks_its_row_again(self, recorder: _Ta
def test_the_cursor_keeps_its_row_on_a_frame_the_playhead_left(self, recorder: _TableRecorder) -> None:
"""A frame the playhead is away from shows the reader's own cursor on the row it sits on."""
panel = _panel()
- _place_cursor(panel, 3, GeneratorName.PULSE1)
+ _place_cursor(panel, 3, ChannelName.PULSE1)
panel.set_playing_position(_playhead(SHOWN_FRAME, 3))
panel._show_frame(OTHER_FRAME)
@@ -459,7 +459,7 @@ def test_the_header_shade_covers_the_sample_and_channel_columns(
panel._highlight_header_row()
- washed: List[Optional[GeneratorName]] = [None, *GeneratorName.items()]
- for generator in washed:
- key = (HEADER_TABLE_ROW, tracker_table_column(generator))
+ washed: List[Optional[ChannelName]] = [None, *ChannelName.items()]
+ for channel in washed:
+ key = (HEADER_TABLE_ROW, tracker_table_column(channel))
assert recorder.highlighted_cells[key] == HEADER_SHADE
diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_typed_voice.py b/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_typed_voice.py
new file mode 100644
index 000000000..cfc27efe1
--- /dev/null
+++ b/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_typed_voice.py
@@ -0,0 +1,161 @@
+from typing import List, Optional, Tuple
+
+import pytest
+
+from sampletones_application.ui.elements.table.cells import EditableCells
+from sampletones_application.ui.panels.sequencer.input.edit import EditAction
+from sampletones_application.ui.panels.sequencer.tracker import panel as tracker_module
+from sampletones_application.view_model.sequencer.subcolumn import SubColumn
+from sampletones_application.view_model.sequencer.voices import (
+ SequencerVoicesViewModel,
+ VoiceEntryViewModel,
+ VoiceKind,
+)
+from sampletones_core.constants.enums import ChannelName
+from sampletones_core.utils.display import display_id
+
+SAMPLE_INDEX = 0
+INSTRUMENT_INDEX = 1
+STORED_LABEL = display_id(None)
+
+Write = Tuple[int, Optional[ChannelName], Optional[str]]
+
+
+class Panel:
+ """A tracker panel holding the pool and the cell cache a typed edit reads and writes.
+
+ The commit path touches only those two and the ``on_set_row`` hook, so the widgets the labels
+ are drawn on stay out of it and the reading under test is what the cache is left holding.
+ """
+
+ def __init__(self) -> None:
+ self.panel = tracker_module.GUISequencerTrackerPanel.__new__(tracker_module.GUISequencerTrackerPanel)
+ self.panel._editable_cells = EditableCells()
+ self.panel._cell_kinds = {}
+ self.panel._current_samples = SequencerVoicesViewModel(
+ voices=(
+ VoiceEntryViewModel(
+ voice_id="lead-id",
+ name="lead",
+ kind=VoiceKind.SAMPLE,
+ ),
+ VoiceEntryViewModel(
+ voice_id="pad-id",
+ name="pad",
+ kind=VoiceKind.INSTRUMENT,
+ ),
+ ),
+ )
+ self.writes: List[Write] = []
+ self.panel.on_set_row = lambda row, channel, voice_id, transpose, volume: self.writes.append(
+ (row, channel, voice_id)
+ )
+
+ def type_voice(self, index: int, channel: Optional[ChannelName]) -> None:
+ self.panel._handle_edit_action(
+ EditAction(
+ row=0,
+ channel=channel,
+ sample_index=index,
+ transpose=None,
+ volume=None,
+ )
+ )
+
+ def shown(self, channel: Optional[ChannelName]) -> str:
+ """The label the cell cache holds, which is what the cell shows once the commit settles."""
+ return self.panel._editable_cells.values.get((0, channel, SubColumn.VOICE), STORED_LABEL)
+
+ def kind(self, channel: Optional[ChannelName]) -> Optional[VoiceKind]:
+ """The kind the cell cache holds, which is the color the slot takes with its number."""
+ return self.panel._cell_kinds.get((0, channel, SubColumn.VOICE))
+
+
+@pytest.fixture
+def panel() -> Panel:
+ return Panel()
+
+
+class TestTypingAVoiceNumber:
+ """The column a number is typed in decides whether the voice it names lands there."""
+
+ def test_a_sample_typed_in_the_sample_column_is_written(self, panel: Panel) -> None:
+ panel.type_voice(SAMPLE_INDEX, None)
+
+ assert panel.writes == [(0, None, "lead-id")]
+ assert panel.shown(None) == display_id(SAMPLE_INDEX)
+
+ def test_an_instrument_typed_in_a_channel_column_is_written(self, panel: Panel) -> None:
+ panel.type_voice(INSTRUMENT_INDEX, ChannelName.NOISE)
+
+ assert panel.writes == [(0, ChannelName.NOISE, "pad-id")]
+ assert panel.shown(ChannelName.NOISE) == display_id(INSTRUMENT_INDEX)
+
+ def test_an_instrument_typed_in_the_sample_column_names_no_voice(self, panel: Panel) -> None:
+ panel.type_voice(INSTRUMENT_INDEX, None)
+
+ assert panel.writes == [(0, None, None)]
+
+ def test_an_instrument_typed_in_the_sample_column_leaves_the_cell_showing_what_it_held(
+ self,
+ panel: Panel,
+ ) -> None:
+ """The refusal changes nothing, so a cell taking the number optimistically would keep it."""
+ panel.type_voice(INSTRUMENT_INDEX, None)
+
+ assert panel.shown(None) == STORED_LABEL
+
+ def test_a_number_past_the_pool_reads_as_the_last_voice(self, panel: Panel) -> None:
+ panel.type_voice(99, ChannelName.PULSE1)
+
+ assert panel.writes == [(0, ChannelName.PULSE1, "pad-id")]
+
+ def test_a_number_past_the_pool_still_answers_to_the_column(self, panel: Panel) -> None:
+ """The last voice is the instrument, which the sample column stands by for."""
+ panel.type_voice(99, None)
+
+ assert panel.writes == [(0, None, None)]
+ assert panel.shown(None) == STORED_LABEL
+
+ def test_typing_into_an_empty_pool_names_no_voice(self, panel: Panel) -> None:
+ panel.panel._current_samples = SequencerVoicesViewModel(voices=())
+
+ panel.type_voice(SAMPLE_INDEX, ChannelName.PULSE1)
+
+ assert panel.writes == [(0, ChannelName.PULSE1, None)]
+ assert panel.shown(ChannelName.PULSE1) == STORED_LABEL
+
+
+class TestWhatColorATypedVoiceTakes:
+ """The cell takes the kind with the number, so a typed voice reads whole before the project answers."""
+
+ def test_a_typed_sample_takes_the_sample_kind(self, panel: Panel) -> None:
+ panel.type_voice(SAMPLE_INDEX, ChannelName.PULSE1)
+
+ assert panel.kind(ChannelName.PULSE1) is VoiceKind.SAMPLE
+
+ def test_a_typed_instrument_takes_the_instrument_kind(self, panel: Panel) -> None:
+ panel.type_voice(INSTRUMENT_INDEX, ChannelName.NOISE)
+
+ assert panel.kind(ChannelName.NOISE) is VoiceKind.INSTRUMENT
+
+ def test_a_refused_voice_leaves_the_cell_its_own_kind(self, panel: Panel) -> None:
+ """Nothing is written, so the slot keeps the color it already wore."""
+ panel.type_voice(INSTRUMENT_INDEX, None)
+
+ assert panel.kind(None) is None
+
+ def test_a_cut_cell_states_no_kind(self, panel: Panel) -> None:
+ panel.panel.on_set_note_off = lambda row, channel: None
+ panel.panel._handle_edit_action(
+ EditAction(
+ row=0,
+ channel=ChannelName.TRIANGLE,
+ sample_index=None,
+ transpose=None,
+ volume=None,
+ note_off=True,
+ )
+ )
+
+ assert panel.kind(ChannelName.TRIANGLE) is None
diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/tracker/__init__.py b/tests/unit/sampletones_application/ui/panels/sequencer/tracker/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_header_menu.py b/tests/unit/sampletones_application/ui/panels/sequencer/tracker/test_header_menu.py
similarity index 74%
rename from tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_header_menu.py
rename to tests/unit/sampletones_application/ui/panels/sequencer/tracker/test_header_menu.py
index 457758d15..f87fccdf9 100644
--- a/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_header_menu.py
+++ b/tests/unit/sampletones_application/ui/panels/sequencer/tracker/test_header_menu.py
@@ -6,15 +6,18 @@
from sampletones_application.categories.manager import LanguageManager
from sampletones_application.paths import LANG_EN
-from sampletones_application.ui.panels.sequencer import tracker as tracker_module
-from sampletones_application.ui.panels.sequencer.tracker import GUISequencerTrackerPanel
+from sampletones_application.ui.panels.sequencer.tracker import menu as menu_module
+from sampletones_application.ui.panels.sequencer.tracker import panel as tracker_module
+from sampletones_application.ui.panels.sequencer.tracker.menu import TrackerMenu
+from sampletones_application.ui.panels.sequencer.tracker.panel import GUISequencerTrackerPanel
from sampletones_application.utils.gui.keyboard.modifiers import Modifier
from sampletones_application.view_model.sequencer.channels import (
SequencerChannelsViewModel,
)
-from sampletones_core.constants.enums import GeneratorName
+from sampletones_core.constants.enums import ChannelName
from sampletones_shared.types.application import Sender
from sampletones_shared.types.callback import VoidCallback
+from tests.suite.shortcuts import shipped_source
SENDER_WIDGET_ID: Sender = 8100
"""A stand-in for the handler-registry id DearPyGui passes as the callback's sender."""
@@ -30,25 +33,25 @@
LABEL_UNMUTE_ALL = "Unmute all channels"
ALL_CHANNEL_LABELS = [LABEL_MUTE_ALL, LABEL_UNMUTE_ALL]
-COLUMN_LABELS: Dict[Optional[GeneratorName], str] = {
+COLUMN_LABELS: Dict[Optional[ChannelName], str] = {
None: "Sample",
- GeneratorName.PULSE1: "Pulse 1",
- GeneratorName.PULSE2: "Pulse 2",
- GeneratorName.TRIANGLE: "Triangle",
- GeneratorName.NOISE: "Noise",
+ ChannelName.PULSE1: "Pulse 1",
+ ChannelName.PULSE2: "Pulse 2",
+ ChannelName.TRIANGLE: "Triangle",
+ ChannelName.NOISE: "Noise",
}
-HEADER_WIDGETS: Dict[Optional[GeneratorName], Sender] = {
+HEADER_WIDGETS: Dict[Optional[ChannelName], Sender] = {
column: 200 + index for index, column in enumerate(COLUMN_LABELS)
}
-FULL_MIX: FrozenSet[GeneratorName] = frozenset()
-EVERY_CHANNEL: FrozenSet[GeneratorName] = frozenset(GeneratorName.items())
+FULL_MIX: FrozenSet[ChannelName] = frozenset()
+EVERY_CHANNEL: FrozenSet[ChannelName] = frozenset(ChannelName.items())
-def _others(generator: GeneratorName) -> FrozenSet[GeneratorName]:
- """The mute set a solo of ``generator`` leaves behind."""
- return EVERY_CHANNEL - {generator}
+def _others(channel: ChannelName) -> FrozenSet[ChannelName]:
+ """The mute set a solo of ``channel`` leaves behind."""
+ return EVERY_CHANNEL - {channel}
class _MenuRecorder:
@@ -84,7 +87,7 @@ def click(self, label: str) -> None:
self.callbacks[label]()
-def _panel(muted: FrozenSet[GeneratorName]) -> GUISequencerTrackerPanel:
+def _panel(muted: FrozenSet[ChannelName]) -> GUISequencerTrackerPanel:
"""Builds a panel around the state the header menu reads, with no DearPyGui context.
The menu touches the column labels, the pushed mute set, and the map from header widget to
@@ -92,31 +95,37 @@ def _panel(muted: FrozenSet[GeneratorName]) -> GUISequencerTrackerPanel:
the menu is built the way the panel builds it, from the real language file, so the item labels
under test are the ones a user reads.
"""
+ language_manager = LanguageManager(LANG_EN)
panel = GUISequencerTrackerPanel.__new__(GUISequencerTrackerPanel)
panel._column_labels = dict(COLUMN_LABELS)
panel._header_columns = {widget: column for column, widget in HEADER_WIDGETS.items()}
panel._current_channels = SequencerChannelsViewModel(muted=muted)
- panel._create_channel_switch(LanguageManager(LANG_EN))
+ panel._create_channel_switch(language_manager)
+ panel._menu = TrackerMenu(
+ panel,
+ language_manager=language_manager,
+ shortcut_source=shipped_source(),
+ )
return panel
@pytest.fixture
def recorder(monkeypatch: pytest.MonkeyPatch) -> _MenuRecorder:
instance = _MenuRecorder()
- monkeypatch.setattr(tracker_module.dpg, "add_menu_item", instance.add_menu_item)
- monkeypatch.setattr(tracker_module.dpg, "add_text", instance.add_text)
- monkeypatch.setattr(tracker_module.dpg, "add_separator", instance.add_separator)
- monkeypatch.setattr(tracker_module.FontRegistry, "bind_to_item", lambda item, font: None)
+ monkeypatch.setattr(menu_module.dpg, "add_menu_item", instance.add_menu_item)
+ monkeypatch.setattr(menu_module.dpg, "add_text", instance.add_text)
+ monkeypatch.setattr(menu_module.dpg, "add_separator", instance.add_separator)
+ monkeypatch.setattr(menu_module.FontRegistry, "bind_to_item", lambda item, font: None)
@contextlib.contextmanager
def _popup() -> Iterator[None]:
yield
- monkeypatch.setattr(tracker_module, "context_menu", _popup)
+ monkeypatch.setattr(menu_module, "context_menu", _popup)
return instance
-def _right_click(panel: GUISequencerTrackerPanel, column: Optional[GeneratorName]) -> None:
+def _right_click(panel: GUISequencerTrackerPanel, column: Optional[ChannelName]) -> None:
panel._on_header_right_clicked(
SENDER_WIDGET_ID,
(dpg.mvMouseButton_Right, HEADER_WIDGETS[column]),
@@ -127,9 +136,9 @@ class TestHeaderRightClickRouting:
def test_a_right_click_opens_the_menu_for_the_clicked_column(self, recorder: _MenuRecorder) -> None:
panel = _panel(FULL_MIX)
- _right_click(panel, GeneratorName.TRIANGLE)
+ _right_click(panel, ChannelName.TRIANGLE)
- assert recorder.titles == [COLUMN_LABELS[GeneratorName.TRIANGLE]]
+ assert recorder.titles == [COLUMN_LABELS[ChannelName.TRIANGLE]]
def test_the_sample_header_opens_the_whole_mix_menu(self, recorder: _MenuRecorder) -> None:
panel = _panel(FULL_MIX)
@@ -149,7 +158,7 @@ def test_only_the_right_button_opens_the_menu(self, recorder: _MenuRecorder, mou
panel._on_header_right_clicked(
SENDER_WIDGET_ID,
- (mouse_button, HEADER_WIDGETS[GeneratorName.NOISE]),
+ (mouse_button, HEADER_WIDGETS[ChannelName.NOISE]),
)
assert not recorder.titles
@@ -164,10 +173,10 @@ def test_a_header_a_rebuild_replaced_opens_nothing(self, recorder: _MenuRecorder
def test_every_channel_header_reaches_its_own_menu(self, recorder: _MenuRecorder) -> None:
panel = _panel(FULL_MIX)
- for generator in GeneratorName.items():
- _right_click(panel, generator)
+ for channel in ChannelName.items():
+ _right_click(panel, channel)
- assert recorder.titles == [COLUMN_LABELS[generator] for generator in GeneratorName.items()]
+ assert recorder.titles == [COLUMN_LABELS[channel] for channel in ChannelName.items()]
class TestChannelItemLabels:
@@ -175,19 +184,19 @@ class TestChannelItemLabels:
"muted, expected",
[
(FULL_MIX, LABEL_MUTE),
- (frozenset({GeneratorName.PULSE1}), LABEL_UNMUTE),
+ (frozenset({ChannelName.PULSE1}), LABEL_UNMUTE),
],
ids=["audible", "silenced"],
)
def test_the_mute_item_names_the_change_it_makes(
self,
recorder: _MenuRecorder,
- muted: FrozenSet[GeneratorName],
+ muted: FrozenSet[ChannelName],
expected: str,
) -> None:
panel = _panel(muted)
- _right_click(panel, GeneratorName.PULSE1)
+ _right_click(panel, ChannelName.PULSE1)
assert expected in recorder.labels
@@ -195,35 +204,35 @@ def test_the_mute_item_names_the_change_it_makes(
"muted, expected",
[
(FULL_MIX, LABEL_SOLO),
- (frozenset({GeneratorName.PULSE2}), LABEL_SOLO),
- (_others(GeneratorName.PULSE1), LABEL_UNSOLO),
+ (frozenset({ChannelName.PULSE2}), LABEL_SOLO),
+ (_others(ChannelName.PULSE1), LABEL_UNSOLO),
],
ids=["full mix", "another channel silenced", "already alone"],
)
def test_the_solo_item_names_the_change_it_makes(
self,
recorder: _MenuRecorder,
- muted: FrozenSet[GeneratorName],
+ muted: FrozenSet[ChannelName],
expected: str,
) -> None:
panel = _panel(muted)
- _right_click(panel, GeneratorName.PULSE1)
+ _right_click(panel, ChannelName.PULSE1)
assert expected in recorder.labels
def test_each_column_reads_its_own_state(self, recorder: _MenuRecorder) -> None:
- panel = _panel(frozenset({GeneratorName.PULSE1}))
+ panel = _panel(frozenset({ChannelName.PULSE1}))
- _right_click(panel, GeneratorName.NOISE)
+ _right_click(panel, ChannelName.NOISE)
assert LABEL_MUTE in recorder.labels
def test_a_solo_elsewhere_leaves_this_channel_offering_a_solo(self, recorder: _MenuRecorder) -> None:
"""A silenced channel of a solo is offered its own solo, which moves the solo onto it."""
- panel = _panel(_others(GeneratorName.PULSE1))
+ panel = _panel(_others(ChannelName.PULSE1))
- _right_click(panel, GeneratorName.PULSE2)
+ _right_click(panel, ChannelName.PULSE2)
assert LABEL_UNMUTE in recorder.labels
assert LABEL_SOLO in recorder.labels
@@ -231,12 +240,12 @@ def test_a_solo_elsewhere_leaves_this_channel_offering_a_solo(self, recorder: _M
def test_a_channel_menu_carries_both_blocks(self, recorder: _MenuRecorder) -> None:
panel = _panel(FULL_MIX)
- _right_click(panel, GeneratorName.NOISE)
+ _right_click(panel, ChannelName.NOISE)
assert recorder.labels == [LABEL_MUTE, LABEL_SOLO, *ALL_CHANNEL_LABELS]
def test_the_sample_menu_addresses_no_single_channel(self, recorder: _MenuRecorder) -> None:
- panel = _panel(frozenset({GeneratorName.NOISE}))
+ panel = _panel(frozenset({ChannelName.NOISE}))
_right_click(panel, None)
@@ -248,13 +257,13 @@ def test_the_sample_menu_addresses_no_single_channel(self, recorder: _MenuRecord
class TestAllChannelItems:
@pytest.mark.parametrize(
"muted",
- [FULL_MIX, frozenset({GeneratorName.NOISE})],
+ [FULL_MIX, frozenset({ChannelName.NOISE})],
ids=["full mix", "one silenced"],
)
def test_muting_everything_is_offered_while_a_channel_sounds(
self,
recorder: _MenuRecorder,
- muted: FrozenSet[GeneratorName],
+ muted: FrozenSet[ChannelName],
) -> None:
panel = _panel(muted)
@@ -271,13 +280,13 @@ def test_muting_everything_is_withheld_in_full_silence(self, recorder: _MenuReco
@pytest.mark.parametrize(
"muted",
- [frozenset({GeneratorName.TRIANGLE}), EVERY_CHANNEL],
+ [frozenset({ChannelName.TRIANGLE}), EVERY_CHANNEL],
ids=["one silenced", "every channel silenced"],
)
def test_restoring_everything_is_offered_while_a_channel_is_silent(
self,
recorder: _MenuRecorder,
- muted: FrozenSet[GeneratorName],
+ muted: FrozenSet[ChannelName],
) -> None:
panel = _panel(muted)
@@ -295,7 +304,7 @@ def test_restoring_everything_is_withheld_in_the_full_mix(self, recorder: _MenuR
def test_both_menus_end_with_the_whole_mix_items(self, recorder: _MenuRecorder) -> None:
panel = _panel(FULL_MIX)
- _right_click(panel, GeneratorName.PULSE1)
+ _right_click(panel, ChannelName.PULSE1)
channel_menu = recorder.labels[-2:]
_right_click(panel, None)
@@ -306,47 +315,47 @@ def test_both_menus_end_with_the_whole_mix_items(self, recorder: _MenuRecorder)
class TestHeaderMenuActions:
def test_the_mute_item_switches_the_clicked_channel(self, recorder: _MenuRecorder) -> None:
panel = _panel(FULL_MIX)
- toggled: List[GeneratorName] = []
+ toggled: List[ChannelName] = []
panel.on_channel_mute_toggled = toggled.append
- _right_click(panel, GeneratorName.TRIANGLE)
+ _right_click(panel, ChannelName.TRIANGLE)
recorder.click(LABEL_MUTE)
- assert toggled == [GeneratorName.TRIANGLE]
+ assert toggled == [ChannelName.TRIANGLE]
def test_the_unmute_item_switches_the_clicked_channel(self, recorder: _MenuRecorder) -> None:
- panel = _panel(frozenset({GeneratorName.TRIANGLE}))
- toggled: List[GeneratorName] = []
+ panel = _panel(frozenset({ChannelName.TRIANGLE}))
+ toggled: List[ChannelName] = []
panel.on_channel_mute_toggled = toggled.append
- _right_click(panel, GeneratorName.TRIANGLE)
+ _right_click(panel, ChannelName.TRIANGLE)
recorder.click(LABEL_UNMUTE)
- assert toggled == [GeneratorName.TRIANGLE]
+ assert toggled == [ChannelName.TRIANGLE]
def test_the_solo_item_solos_the_clicked_channel(self, recorder: _MenuRecorder) -> None:
panel = _panel(FULL_MIX)
- soloed: List[GeneratorName] = []
+ soloed: List[ChannelName] = []
panel.on_channel_soloed = soloed.append
- _right_click(panel, GeneratorName.NOISE)
+ _right_click(panel, ChannelName.NOISE)
recorder.click(LABEL_SOLO)
- assert soloed == [GeneratorName.NOISE]
+ assert soloed == [ChannelName.NOISE]
def test_the_unsolo_item_takes_the_same_route_back(self, recorder: _MenuRecorder) -> None:
"""Leaving a solo restores the mix the solo interrupted, which the channels logic owns."""
- panel = _panel(_others(GeneratorName.NOISE))
- soloed: List[GeneratorName] = []
+ panel = _panel(_others(ChannelName.NOISE))
+ soloed: List[ChannelName] = []
panel.on_channel_soloed = soloed.append
- _right_click(panel, GeneratorName.NOISE)
+ _right_click(panel, ChannelName.NOISE)
recorder.click(LABEL_UNSOLO)
- assert soloed == [GeneratorName.NOISE]
+ assert soloed == [ChannelName.NOISE]
def test_muting_everything_reaches_its_own_hook(self, recorder: _MenuRecorder) -> None:
- panel = _panel(frozenset({GeneratorName.PULSE1}))
+ panel = _panel(frozenset({ChannelName.PULSE1}))
muted: List[None] = []
panel.on_channels_muted = lambda: muted.append(None)
panel.on_channels_toggled = lambda: pytest.fail("the menu names the state it leaves")
@@ -357,7 +366,7 @@ def test_muting_everything_reaches_its_own_hook(self, recorder: _MenuRecorder) -
assert muted == [None]
def test_restoring_everything_reaches_its_own_hook(self, recorder: _MenuRecorder) -> None:
- panel = _panel(frozenset({GeneratorName.PULSE1}))
+ panel = _panel(frozenset({ChannelName.PULSE1}))
unmuted: List[None] = []
panel.on_channels_unmuted = lambda: unmuted.append(None)
panel.on_channels_toggled = lambda: pytest.fail("the menu names the state it leaves")
@@ -372,7 +381,7 @@ def test_a_channel_menu_reaches_the_whole_mix_too(self, recorder: _MenuRecorder)
muted: List[None] = []
panel.on_channels_muted = lambda: muted.append(None)
- _right_click(panel, GeneratorName.PULSE2)
+ _right_click(panel, ChannelName.PULSE2)
recorder.click(LABEL_MUTE_ALL)
assert muted == [None]
diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/tracker/test_menu.py b/tests/unit/sampletones_application/ui/panels/sequencer/tracker/test_menu.py
new file mode 100644
index 000000000..fada8fe73
--- /dev/null
+++ b/tests/unit/sampletones_application/ui/panels/sequencer/tracker/test_menu.py
@@ -0,0 +1,248 @@
+import contextlib
+from typing import Any, Dict, Iterator, List, Optional, Tuple
+
+import pytest
+
+from sampletones_application.categories.manager import LanguageManager
+from sampletones_application.paths import LANG_EN
+from sampletones_application.ui.panels.sequencer.input.target import TrackerTarget
+from sampletones_application.ui.panels.sequencer.input.tracker import (
+ TrackerCursor,
+ TrackerInputState,
+)
+from sampletones_application.ui.panels.sequencer.tracker import adjust
+from sampletones_application.ui.panels.sequencer.tracker import menu as menu_module
+from sampletones_application.ui.panels.sequencer.tracker.menu import TrackerMenu
+from sampletones_application.view_model.sequencer.region import TrackerRegion
+from sampletones_application.view_model.sequencer.subcolumn import SubColumn
+from sampletones_application.view_model.sequencer.voices import VoiceEntryViewModel, VoiceKind
+from sampletones_core.constants.enums import ChannelName
+from sampletones_shared.constants.music import OCTAVE_SEMITONES, SEMITONE_STEP
+from tests.suite.shortcuts import shipped_source
+
+SENDER_WIDGET_ID = 6099
+"""A stand-in for the menu-item widget id DearPyGui passes as the callback's first
+positional argument. The original bug let this id overwrite the step payload."""
+
+
+class _MenuHost:
+ """The panel surface a menu reads, wired to just what these cases touch."""
+
+ def __init__(self) -> None:
+ self.on_clear_row = None
+ self.on_clear_subcolumn = None
+ self.on_set_row = None
+ self.on_set_note_off = None
+ self.on_play_from_row = None
+ self.on_play_from_frame = None
+ self.on_adjust_transpose = None
+ self.on_adjust_volume = None
+ self._voices: Tuple[VoiceEntryViewModel, ...] = ()
+
+ @property
+ def edit_surface(self) -> Any:
+ raise NotImplementedError
+
+ @property
+ def channel_switch(self) -> Any:
+ raise NotImplementedError
+
+ @property
+ def channels(self) -> Any:
+ return None
+
+ @property
+ def voices(self) -> Tuple[VoiceEntryViewModel, ...]:
+ return self._voices
+
+ def hold_voices(self, *voices: VoiceEntryViewModel) -> None:
+ self._voices = voices
+
+ def column_label(self, channel: Optional[ChannelName]) -> str:
+ return ""
+
+ def select_shape(self, shortcut_id: Any, cell: TrackerCursor) -> bool:
+ return True
+
+
+def _menu() -> Tuple[TrackerMenu, _MenuHost]:
+ """A menu over a bare host, which is all the item builders under test reach."""
+ host = _MenuHost()
+ return (
+ TrackerMenu(
+ host,
+ language_manager=LanguageManager(LANG_EN),
+ shortcut_source=shipped_source(),
+ ),
+ host,
+ )
+
+
+class _MenuItemRecorder:
+ """Captures the ``user_data``/``callback`` pairs the builders register."""
+
+ def __init__(self) -> None:
+ self.items: List[Tuple[Any, Any]] = []
+ self.entries: List[Dict[str, Any]] = []
+
+ def add_menu_item(self, **kwargs: Any) -> int:
+ self.entries.append(kwargs)
+ if "callback" in kwargs and "user_data" in kwargs:
+ self.items.append((kwargs["user_data"], kwargs["callback"]))
+ return 0
+
+ def reachable(self, label: str) -> bool:
+ """Whether the one item carrying ``label`` was offered enabled."""
+ entries = [entry for entry in self.entries if entry["label"] == label]
+ assert len(entries) == 1, f"{label!r} appears {len(entries)} times"
+ return bool(entries[0]["enabled"])
+
+ def dispatch_as_dpg(self) -> None:
+ """Fires each recorded callback the way DearPyGui does: sender first."""
+ for user_data, callback in self.items:
+ callback(SENDER_WIDGET_ID, None, user_data)
+
+
+@pytest.fixture
+def recorder(monkeypatch: pytest.MonkeyPatch) -> _MenuItemRecorder:
+ instance = _MenuItemRecorder()
+ monkeypatch.setattr(menu_module.dpg, "add_menu_item", instance.add_menu_item)
+
+ @contextlib.contextmanager
+ def _menu(**kwargs: Any) -> Iterator[None]:
+ yield
+
+ monkeypatch.setattr(menu_module.dpg, "menu", _menu)
+ return instance
+
+
+def _cell(row: int, channel: Optional[ChannelName]) -> TrackerCursor:
+ """The cell a menu was raised on, which the items carry as their payload."""
+ return TrackerCursor(row, channel, SubColumn.VOICE)
+
+
+def _target(row: int, channel: ChannelName) -> TrackerTarget:
+ """The cell a menu was raised on, paired with the block of that cell alone."""
+ cell = _cell(row, channel)
+ return TrackerTarget(cell=cell, region=TrackerInputState().region_at(cell))
+
+
+class TestMenuDispatchPreservesPayload:
+ def test_transpose_items_pass_the_configured_step(self, recorder: _MenuItemRecorder) -> None:
+ menu, panel = _menu()
+ deltas: List[int] = []
+ panel.on_adjust_transpose = lambda region, delta: deltas.append(delta)
+
+ menu._add_transpose_items(_target(2, ChannelName.PULSE1))
+ recorder.dispatch_as_dpg()
+
+ assert deltas == [
+ SEMITONE_STEP,
+ -SEMITONE_STEP,
+ OCTAVE_SEMITONES,
+ -OCTAVE_SEMITONES,
+ ]
+
+ def test_volume_items_pass_the_configured_step(self, recorder: _MenuItemRecorder) -> None:
+ menu, panel = _menu()
+ deltas: List[int] = []
+ panel.on_adjust_volume = lambda region, delta: deltas.append(delta)
+
+ menu._add_volume_items(_target(2, ChannelName.PULSE1))
+ recorder.dispatch_as_dpg()
+
+ assert deltas == [
+ adjust.VOLUME_FINE_STEP,
+ -adjust.VOLUME_FINE_STEP,
+ adjust.VOLUME_COARSE_STEP,
+ -adjust.VOLUME_COARSE_STEP,
+ ]
+
+ def test_adjust_carries_the_block_the_menu_was_raised_on(self, recorder: _MenuItemRecorder) -> None:
+ menu, panel = _menu()
+ calls: List[Tuple[TrackerRegion, int]] = []
+ panel.on_adjust_transpose = lambda region, delta: calls.append((region, delta))
+ target = _target(7, ChannelName.TRIANGLE)
+
+ menu._add_transpose_items(target)
+ recorder.dispatch_as_dpg()
+
+ assert calls[0] == (target.region, SEMITONE_STEP)
+
+ def test_instrument_items_pass_the_voice_id(self, recorder: _MenuItemRecorder) -> None:
+ menu, panel = _menu()
+ panel.hold_voices(
+ VoiceEntryViewModel(
+ voice_id="lead-id",
+ name="lead",
+ kind=VoiceKind.SAMPLE,
+ ),
+ )
+ chosen: List[str] = []
+ panel.on_set_row = lambda row, channel, voice_id, transpose, volume: chosen.append(voice_id)
+
+ menu._add_voice_submenu(_cell(0, ChannelName.PULSE2))
+ recorder.dispatch_as_dpg()
+
+ assert chosen == ["lead-id"]
+
+
+class TestWhichVoicesAColumnOffers:
+ """The whole pool is listed wherever the menu is raised; the column decides what is reachable."""
+
+ SAMPLE_LABEL = "00 lead"
+ INSTRUMENT_LABEL = "01 pad"
+
+ @staticmethod
+ def _menu_with_both_kinds() -> Tuple[TrackerMenu, "_MenuHost"]:
+ menu, host = _menu()
+ host.hold_voices(
+ VoiceEntryViewModel(
+ voice_id="lead-id",
+ name="lead",
+ kind=VoiceKind.SAMPLE,
+ ),
+ VoiceEntryViewModel(
+ voice_id="pad-id",
+ name="pad",
+ kind=VoiceKind.INSTRUMENT,
+ ),
+ )
+ return menu, host
+
+ def test_a_channel_column_reaches_both_kinds(self, recorder: _MenuItemRecorder) -> None:
+ menu, panel = self._menu_with_both_kinds()
+
+ menu._add_voice_submenu(_cell(0, ChannelName.PULSE2))
+
+ assert recorder.reachable(self.SAMPLE_LABEL) is True
+ assert recorder.reachable(self.INSTRUMENT_LABEL) is True
+
+ def test_the_sample_column_reaches_a_sample_alone(self, recorder: _MenuItemRecorder) -> None:
+ menu, panel = self._menu_with_both_kinds()
+
+ menu._add_voice_submenu(_cell(0, None))
+
+ assert recorder.reachable(self.SAMPLE_LABEL) is True
+ assert recorder.reachable(self.INSTRUMENT_LABEL) is False
+
+ def test_the_sample_column_still_names_the_instrument_it_stands_by_for(
+ self,
+ recorder: _MenuItemRecorder,
+ ) -> None:
+ """An unreachable item says the voice exists while leaving it where it belongs."""
+ menu, panel = self._menu_with_both_kinds()
+
+ menu._add_voice_submenu(_cell(0, None))
+
+ assert [entry["label"] for entry in recorder.entries] == [
+ self.SAMPLE_LABEL,
+ self.INSTRUMENT_LABEL,
+ ]
+
+ def test_an_empty_pool_offers_one_unreachable_item(self, recorder: _MenuItemRecorder) -> None:
+ menu, _ = _menu()
+
+ menu._add_voice_submenu(_cell(0, None))
+
+ assert [entry["enabled"] for entry in recorder.entries] == [False]
diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/tracker/test_themes.py b/tests/unit/sampletones_application/ui/panels/sequencer/tracker/test_themes.py
new file mode 100644
index 000000000..56b6cd045
--- /dev/null
+++ b/tests/unit/sampletones_application/ui/panels/sequencer/tracker/test_themes.py
@@ -0,0 +1,94 @@
+from types import SimpleNamespace
+from typing import Any, Dict, List, Tuple
+
+import pytest
+
+from sampletones_application.ui.panels.sequencer.tracker import themes as themes_module
+from sampletones_application.ui.panels.sequencer.tracker.callbacks import ThemeKey
+from sampletones_application.ui.panels.sequencer.tracker.themes import TrackerThemes
+from sampletones_application.utils.palette.colors.base import BaseColor
+from sampletones_application.utils.palette.colors.written import LiteralColor
+from sampletones_application.view_model.sequencer.subcolumn import SubColumn
+from sampletones_application.view_model.sequencer.voices import VoiceKind
+
+MUTED_TEXT_FRACTION = 0.25
+
+TEXT_COLORS = SimpleNamespace(
+ voice=LiteralColor((118, 122, 142, 255)),
+ sample=LiteralColor((224, 200, 96, 255)),
+ instrument=LiteralColor((255, 112, 223, 255)),
+ transpose=LiteralColor((192, 192, 192, 255)),
+ volume=LiteralColor((100, 220, 100, 255)),
+ row=LiteralColor((90, 90, 90, 255)),
+)
+
+LAYOUT = SimpleNamespace(
+ colors=SimpleNamespace(text=TEXT_COLORS),
+ tracker=SimpleNamespace(muted_text_fraction=MUTED_TEXT_FRACTION),
+)
+
+
+class TestWhichThemesAreBuilt:
+ """Every shade a cell can wear is built once, each kind in its own color and each with a twin."""
+
+ @staticmethod
+ def _built(monkeypatch: pytest.MonkeyPatch) -> Tuple[Dict[ThemeKey, int], Dict[ThemeKey, int], List[BaseColor]]:
+ colors: List[BaseColor] = []
+
+ def _record(color: BaseColor, *_arguments: Any) -> int:
+ colors.append(color)
+ return len(colors)
+
+ monkeypatch.setattr(themes_module, "create_selectable_text_theme", _record)
+ themes = TrackerThemes(LAYOUT)
+ themes._create_subcolumn_themes()
+ return themes._subcolumn, themes._muted_subcolumn, colors
+
+ def test_the_voice_slot_is_built_in_a_color_for_each_kind(self, monkeypatch: pytest.MonkeyPatch) -> None:
+ subcolumn, _, _ = self._built(monkeypatch)
+
+ voice_themes = {theme_key for theme_key in subcolumn if theme_key[0] is SubColumn.VOICE}
+
+ assert voice_themes == {
+ (SubColumn.VOICE, None),
+ (SubColumn.VOICE, VoiceKind.SAMPLE),
+ (SubColumn.VOICE, VoiceKind.INSTRUMENT),
+ }
+
+ def test_each_kind_is_built_in_its_own_color(self, monkeypatch: pytest.MonkeyPatch) -> None:
+ subcolumn, _, colors = self._built(monkeypatch)
+
+ sample = colors[subcolumn[(SubColumn.VOICE, VoiceKind.SAMPLE)] - 1]
+ instrument = colors[subcolumn[(SubColumn.VOICE, VoiceKind.INSTRUMENT)] - 1]
+
+ assert (sample.rgba, instrument.rgba) == (TEXT_COLORS.sample.rgba, TEXT_COLORS.instrument.rgba)
+
+ def test_every_theme_has_a_dimmed_twin(self, monkeypatch: pytest.MonkeyPatch) -> None:
+ subcolumn, muted_subcolumn, _ = self._built(monkeypatch)
+
+ assert set(muted_subcolumn) == set(subcolumn)
+
+
+class TestWhichThemeACellWears:
+ """A cell reads its shade from the slot it stands in and whether its channel is silenced."""
+
+ @staticmethod
+ def _themes() -> TrackerThemes:
+ themes = TrackerThemes.__new__(TrackerThemes)
+ themes._subcolumn = {(SubColumn.VOICE, VoiceKind.SAMPLE): 11, (SubColumn.TRANSPOSE, None): 13}
+ themes._muted_subcolumn = {(SubColumn.VOICE, VoiceKind.SAMPLE): 111, (SubColumn.TRANSPOSE, None): 113}
+ themes._header = 20
+ themes._muted_header = 120
+ return themes
+
+ def test_an_audible_cell_takes_the_full_shade(self) -> None:
+ assert self._themes().cell(SubColumn.VOICE, VoiceKind.SAMPLE, muted=False) == 11
+
+ def test_a_silenced_cell_takes_the_dimmed_twin(self) -> None:
+ assert self._themes().cell(SubColumn.VOICE, VoiceKind.SAMPLE, muted=True) == 111
+
+ def test_an_audible_header_takes_the_full_shade(self) -> None:
+ assert self._themes().header(muted=False) == 20
+
+ def test_a_silenced_header_takes_the_dimmed_twin(self) -> None:
+ assert self._themes().header(muted=True) == 120
diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/voices/__init__.py b/tests/unit/sampletones_application/ui/panels/sequencer/voices/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/voices/test_hover.py b/tests/unit/sampletones_application/ui/panels/sequencer/voices/test_hover.py
new file mode 100644
index 000000000..ee54015ba
--- /dev/null
+++ b/tests/unit/sampletones_application/ui/panels/sequencer/voices/test_hover.py
@@ -0,0 +1,94 @@
+from typing import Final, Optional, Tuple
+
+import pytest
+
+from sampletones_application.categories.manager import LanguageManager
+from sampletones_application.paths import LANG_EN
+from sampletones_application.ui.panels.sequencer.voices.footprint import (
+ VoiceFootprintText,
+)
+from sampletones_application.ui.panels.sequencer.voices.panel import (
+ GUISequencerVoicesPanel,
+)
+from sampletones_application.view_model.sequencer.voices import (
+ VoiceEntryViewModel,
+ VoiceKind,
+)
+from sampletones_application.view_model.shared.footprint import VoiceFootprintViewModel
+from sampletones_core.constants.enums import ChannelName
+from sampletones_core.formats.famitracker.footprint import InstrumentFootprint
+
+SAMPLE_ID: Final[str] = "kick-id"
+INSTRUMENT_ID: Final[str] = "lead-id"
+UNKNOWN_ID: Final[str] = "gone-id"
+
+ENTRIES: Final[Tuple[VoiceEntryViewModel, ...]] = (
+ VoiceEntryViewModel(voice_id=SAMPLE_ID, name="Kick", kind=VoiceKind.SAMPLE),
+ VoiceEntryViewModel(voice_id=INSTRUMENT_ID, name="Lead", kind=VoiceKind.INSTRUMENT),
+)
+
+PULSE_1_FOOTPRINT: Final[InstrumentFootprint] = InstrumentFootprint(instrument_bytes=9, sequence_bytes=32)
+NOISE_FOOTPRINT: Final[InstrumentFootprint] = InstrumentFootprint(instrument_bytes=7, sequence_bytes=12)
+SAMPLE_FOOTPRINT: Final[VoiceFootprintViewModel] = VoiceFootprintViewModel.from_footprints(
+ {
+ ChannelName.PULSE1: PULSE_1_FOOTPRINT,
+ ChannelName.NOISE: NOISE_FOOTPRINT,
+ }
+)
+INSTRUMENT_FOOTPRINT: Final[VoiceFootprintViewModel] = VoiceFootprintViewModel.from_instrument(PULSE_1_FOOTPRINT)
+
+
+def _panel(footprint: Optional[VoiceFootprintViewModel]) -> GUISequencerVoicesPanel:
+ """The panel over the facts a hovered row reads, with no DearPyGui context behind it."""
+ language_manager = LanguageManager(LANG_EN)
+ panel = GUISequencerVoicesPanel.__new__(GUISequencerVoicesPanel)
+ panel._entries = ENTRIES
+ panel._footprint_text = VoiceFootprintText(language_manager)
+ panel._tpl_status_sample = language_manager["sequencer.voices.template.status_sample"]
+ panel._tpl_status_instrument = language_manager["sequencer.voices.template.status_instrument"]
+ panel._channel_separator = language_manager["sequencer.voices.template.status_channel_separator"]
+ panel.voice_footprint = lambda _voice_id: footprint
+ return panel
+
+
+class TestWhatARowSaysAboutItsVoice:
+ def test_a_sample_names_the_channels_it_plays(self) -> None:
+ message = _panel(SAMPLE_FOOTPRINT)._voice_status_message(SAMPLE_ID)
+
+ assert "Pulse 1" in message
+ assert "Noise" in message
+ assert "Pulse 2" not in message
+
+ def test_a_sample_states_what_it_costs_as_one_figure(self) -> None:
+ message = _panel(SAMPLE_FOOTPRINT)._voice_status_message(SAMPLE_ID)
+
+ assert f"{SAMPLE_FOOTPRINT.total_bytes} B" in message
+
+ def test_a_sample_is_named_and_called_a_sample(self) -> None:
+ message = _panel(SAMPLE_FOOTPRINT)._voice_status_message(SAMPLE_ID)
+
+ assert message.startswith("Kick")
+ assert "sample" in message
+
+ def test_an_instrument_names_no_channel_and_says_every_one_can_play_it(self) -> None:
+ message = _panel(INSTRUMENT_FOOTPRINT)._voice_status_message(INSTRUMENT_ID)
+
+ assert message.startswith("Lead")
+ assert "instrument" in message
+ assert "Pulse 1" not in message
+ assert f"{PULSE_1_FOOTPRINT.total_bytes} B" in message
+
+ @pytest.mark.parametrize(
+ ("voice_id", "footprint"),
+ [
+ (UNKNOWN_ID, SAMPLE_FOOTPRINT),
+ (SAMPLE_ID, None),
+ ],
+ ids=["voice_the_pool_no_longer_holds", "voice_nothing_measures"],
+ )
+ def test_a_voice_with_nothing_to_state_says_nothing(
+ self,
+ voice_id: str,
+ footprint: Optional[VoiceFootprintViewModel],
+ ) -> None:
+ assert _panel(footprint)._voice_status_message(voice_id) == ""
diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/voices/test_keys.py b/tests/unit/sampletones_application/ui/panels/sequencer/voices/test_keys.py
new file mode 100644
index 000000000..ba3af24cb
--- /dev/null
+++ b/tests/unit/sampletones_application/ui/panels/sequencer/voices/test_keys.py
@@ -0,0 +1,105 @@
+from dataclasses import dataclass, field
+from typing import List, Tuple
+
+import pytest
+
+from sampletones_application.ui.panels.sequencer.voices.panel import GUISequencerVoicesPanel
+from sampletones_application.utils.gui.keyboard.combination import KeyCombination
+from sampletones_application.utils.gui.keyboard.event import KeyEvent
+from sampletones_application.view_model.sequencer.voices import VoiceEntryViewModel, VoiceKind
+from tests.suite.shortcuts import shipped_source
+
+ENTRIES: Tuple[VoiceEntryViewModel, ...] = (
+ VoiceEntryViewModel(voice_id="kick-id", name="Kick", kind=VoiceKind.SAMPLE),
+ VoiceEntryViewModel(voice_id="bass-id", name="Bass", kind=VoiceKind.SAMPLE),
+ VoiceEntryViewModel(voice_id="lead-id", name="Lead", kind=VoiceKind.SAMPLE),
+)
+
+SELECTED_ID = "bass-id"
+SELECTED_ROW = 1
+
+Move = Tuple[str, int]
+
+
+@dataclass
+class VoicesPanelFixture:
+ """A panel carrying the state the key path reads, with the calls each action makes recorded."""
+
+ panel: GUISequencerVoicesPanel
+ removed: List[str] = field(default_factory=list)
+ moved: List[Move] = field(default_factory=list)
+ renamed: List[str] = field(default_factory=list)
+ canceled: List[None] = field(default_factory=list)
+
+
+@pytest.fixture
+def voices(monkeypatch: pytest.MonkeyPatch) -> VoicesPanelFixture:
+ panel = GUISequencerVoicesPanel.__new__(GUISequencerVoicesPanel)
+ panel._shortcuts = shipped_source()
+ panel._entries = ENTRIES
+ panel._selected_voice_id = SELECTED_ID
+ panel._selected_row = SELECTED_ROW
+ panel._editing_voice_id = None
+
+ fixture = VoicesPanelFixture(panel=panel)
+ panel.on_remove_requested = fixture.removed.append
+ panel.on_move_requested = lambda voice_id, target: fixture.moved.append((voice_id, target))
+ monkeypatch.setattr(panel, "start_rename", fixture.renamed.append)
+ monkeypatch.setattr(panel, "_cancel_rename", lambda: fixture.canceled.append(None))
+ return fixture
+
+
+def _press(text: str) -> KeyEvent:
+ """The press a written combination names, as the router delivers it."""
+ combination = KeyCombination.parse(text)
+ return KeyEvent(key=combination.key, modifiers=combination.modifiers)
+
+
+class TestSelectedSampleActions:
+ def test_the_remove_key_removes_the_selected_sample(self, voices: VoicesPanelFixture) -> None:
+ assert voices.panel._on_key_pressed(_press("Del")) is True
+ assert voices.removed == [SELECTED_ID]
+
+ def test_the_rename_key_starts_the_rename(self, voices: VoicesPanelFixture) -> None:
+ assert voices.panel._on_key_pressed(_press("F2")) is True
+ assert voices.renamed == [SELECTED_ID]
+
+ def test_a_press_the_panel_leaves_unnamed_reaches_the_application(self, voices: VoicesPanelFixture) -> None:
+ assert voices.panel._on_key_pressed(_press("Ctrl+S")) is False
+ assert voices.removed == []
+
+ def test_a_press_without_a_selection_reaches_the_application(self, voices: VoicesPanelFixture) -> None:
+ voices.panel._selected_voice_id = None
+
+ assert voices.panel._on_key_pressed(_press("Del")) is False
+
+
+class TestSampleMoves:
+ def test_the_move_up_key_moves_the_sample_one_row_back(self, voices: VoicesPanelFixture) -> None:
+ assert voices.panel._on_key_pressed(_press("Alt+Up")) is True
+ assert voices.moved == [(SELECTED_ID, SELECTED_ROW - 1)]
+
+ def test_the_move_to_bottom_key_moves_the_sample_last(self, voices: VoicesPanelFixture) -> None:
+ assert voices.panel._on_key_pressed(_press("Alt+End")) is True
+ assert voices.moved == [(SELECTED_ID, len(ENTRIES) - 1)]
+
+ def test_a_move_with_nowhere_to_go_still_consumes_the_key(self, voices: VoicesPanelFixture) -> None:
+ voices.panel._selected_row = 0
+
+ assert voices.panel._on_key_pressed(_press("Alt+Up")) is True
+ assert voices.moved == []
+
+
+class TestRenameInProgress:
+ def test_the_cancel_key_drops_the_name_being_edited(self, voices: VoicesPanelFixture) -> None:
+ voices.panel._editing_voice_id = SELECTED_ID
+
+ assert voices.panel._on_key_pressed(_press("Esc")) is True
+ assert voices.canceled == [None]
+
+ def test_every_other_key_stays_with_the_field(self, voices: VoicesPanelFixture) -> None:
+ """A rename keeps the keyboard, so typing a name reaches the input rather than the list."""
+ voices.panel._editing_voice_id = SELECTED_ID
+
+ assert voices.panel._on_key_pressed(_press("Del")) is False
+ assert voices.removed == []
diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/voices/test_kind_color.py b/tests/unit/sampletones_application/ui/panels/sequencer/voices/test_kind_color.py
new file mode 100644
index 000000000..d7f26fd83
--- /dev/null
+++ b/tests/unit/sampletones_application/ui/panels/sequencer/voices/test_kind_color.py
@@ -0,0 +1,53 @@
+import pytest
+
+from sampletones_application.layout.config import LayoutConfig
+from sampletones_application.layout.loader import load_layout_config
+from sampletones_application.layout.tabs.sequencer import SequencerLayout
+from sampletones_application.paths import (
+ BEHAVIOR_DIRECTORY,
+ LAYOUT_DIRECTORY,
+ PALETTES_DIRECTORY,
+)
+from sampletones_application.ui.panels.sequencer.voices.panel import GUISequencerVoicesPanel
+from sampletones_application.utils.palette.catalog import PaletteCatalog
+from sampletones_application.utils.palette.source import PaletteSource
+from sampletones_application.view_model.sequencer.voices import VoiceKind
+
+
+@pytest.fixture
+def layout_config() -> LayoutConfig:
+ source = PaletteSource(PaletteCatalog.load(PALETTES_DIRECTORY).default)
+ return load_layout_config(LAYOUT_DIRECTORY, BEHAVIOR_DIRECTORY, source)
+
+
+@pytest.fixture
+def sequencer_layout(layout_config: LayoutConfig) -> SequencerLayout:
+ return layout_config.tabs.sequencer
+
+
+def _panel(sequencer_layout: SequencerLayout) -> GUISequencerVoicesPanel:
+ """Builds a panel without its DearPyGui-dependent constructor.
+
+ The kind's color is read from the layout alone, so a running GUI context is unnecessary here.
+ """
+ panel = GUISequencerVoicesPanel.__new__(GUISequencerVoicesPanel)
+ panel._layout = sequencer_layout
+ return panel
+
+
+class TestWhatColorAKindWears:
+ def test_a_sample_wears_the_sample_color(self, sequencer_layout: SequencerLayout) -> None:
+ panel = _panel(sequencer_layout)
+
+ assert panel._kind_color(VoiceKind.SAMPLE) is sequencer_layout.colors.text.sample
+
+ def test_an_instrument_wears_the_instrument_color(self, sequencer_layout: SequencerLayout) -> None:
+ panel = _panel(sequencer_layout)
+
+ assert panel._kind_color(VoiceKind.INSTRUMENT) is sequencer_layout.colors.text.instrument
+
+ def test_the_two_kinds_are_told_apart(self, sequencer_layout: SequencerLayout) -> None:
+ """The color carries the kind, so a list of one hue would say nothing the glyph does not."""
+ panel = _panel(sequencer_layout)
+
+ assert panel._kind_color(VoiceKind.SAMPLE).rgba != panel._kind_color(VoiceKind.INSTRUMENT).rgba
diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/voices/test_menu.py b/tests/unit/sampletones_application/ui/panels/sequencer/voices/test_menu.py
new file mode 100644
index 000000000..43a928be5
--- /dev/null
+++ b/tests/unit/sampletones_application/ui/panels/sequencer/voices/test_menu.py
@@ -0,0 +1,737 @@
+import contextlib
+from dataclasses import dataclass, field
+from typing import Any, Callable, Iterator, List, Optional, Tuple
+
+import pytest
+
+from sampletones_application.categories.elements.global_ import ContextElements
+from sampletones_application.categories.elements.sequencer import SequencerVoicesElements
+from sampletones_application.ui.elements import context_menu as context_menu_module
+from sampletones_application.ui.elements.fonts.registry import FontRegistry
+from sampletones_application.ui.panels.sequencer.voices import menu as menu_module
+from sampletones_application.ui.panels.sequencer.voices import panel as panel_module
+from sampletones_application.ui.panels.sequencer.voices.menu import VoicesMenu
+from sampletones_application.ui.panels.sequencer.voices.moves import VOICE_MOVES
+from sampletones_application.ui.panels.sequencer.voices.panel import GUISequencerVoicesPanel
+from sampletones_application.utils.gui.shortcuts.ids import ShortcutId
+from sampletones_application.utils.palette.colors.literal import LiteralColor
+from sampletones_application.view_model.sequencer.voices import VoiceEntryViewModel, VoiceKind
+from sampletones_application.view_model.shared.footprint import VoiceFootprintViewModel
+from sampletones_core.constants.enums import ChannelName
+from sampletones_core.formats.famitracker.footprint import InstrumentFootprint
+from sampletones_core.utils.display import display_voice_label
+from sampletones_shared.types.callback import VoidCallback
+from tests.suite.shortcuts import shipped_source
+
+ENTRIES: Tuple[VoiceEntryViewModel, ...] = (
+ VoiceEntryViewModel(voice_id="kick-id", name="Kick", kind=VoiceKind.SAMPLE),
+ VoiceEntryViewModel(voice_id="bass-id", name="Bass", kind=VoiceKind.SAMPLE),
+ VoiceEntryViewModel(voice_id="lead-id", name="Lead", kind=VoiceKind.SAMPLE),
+)
+
+SELECTED_ID = "bass-id"
+SELECTED_ROW = 1
+
+SAMPLE_SIZE_LABEL = "Sample size"
+SIZE_TEMPLATE = "{bytes} B"
+SIZE_TOOLTIP = "Bytes a FamiTracker export spends."
+DETAIL_COLOR = LiteralColor((0, 0, 0, 255))
+
+PULSE_1_FOOTPRINT = InstrumentFootprint(instrument_bytes=9, sequence_bytes=32)
+NOISE_FOOTPRINT = InstrumentFootprint(instrument_bytes=7, sequence_bytes=12)
+PULSE_1_BYTES = PULSE_1_FOOTPRINT.total_bytes
+NOISE_BYTES = NOISE_FOOTPRINT.total_bytes
+FOOTPRINT = VoiceFootprintViewModel.from_footprints(
+ {
+ ChannelName.PULSE1: PULSE_1_FOOTPRINT,
+ ChannelName.NOISE: NOISE_FOOTPRINT,
+ }
+)
+
+RIGHT_BUTTON = 1
+
+EDIT_ITEM = 0
+RENAME_ITEM = 1
+DUPLICATE_ITEM = 2
+INSTRUMENT_FROM_ITEM = 3
+REMOVE_ITEM = 4
+MOVE_UP_ITEM = 5
+MOVE_DOWN_ITEM = 6
+MOVE_TOP_ITEM = 7
+MOVE_BOTTOM_ITEM = 8
+EXPORT_ITEM = 9
+
+ONE_INSTRUMENT: Tuple[Optional[ChannelName], ...] = (ChannelName.PULSE1,)
+TWO_INSTRUMENTS: Tuple[Optional[ChannelName], ...] = (ChannelName.PULSE1, ChannelName.NOISE)
+NO_INSTRUMENTS: Tuple[Optional[ChannelName], ...] = ()
+
+TWO_CHANNELS: Tuple[ChannelName, ...] = (ChannelName.PULSE1, ChannelName.NOISE)
+ONE_CHANNEL: Tuple[ChannelName, ...] = (ChannelName.TRIANGLE,)
+NO_CHANNELS: Tuple[ChannelName, ...] = ()
+
+
+def _unreachable() -> None:
+ """Stands where a grayed-out item would carry a callback, which a reader never fires."""
+
+
+@dataclass
+class MenuItem:
+ """One item as it was registered, which is the whole of what a reader sees and clicks."""
+
+ label: str
+ shortcut: str
+ enabled: bool
+ callback: Callable[[], None]
+
+
+@dataclass
+class Requests:
+ """What each voice hook was handed when its menu item fired."""
+
+ edited: List[str] = field(default_factory=list)
+ renamed: List[str] = field(default_factory=list)
+ duplicated: List[str] = field(default_factory=list)
+ removed: List[str] = field(default_factory=list)
+ moved: List[Tuple[str, Optional[int]]] = field(default_factory=list)
+ pool: List[str] = field(default_factory=list)
+ exported: List[Tuple[str, Optional[ChannelName]]] = field(default_factory=list)
+ taken: List[Tuple[str, ChannelName]] = field(default_factory=list)
+
+
+class _MenuRecorder:
+ def __init__(self) -> None:
+ self.items: List[MenuItem] = []
+ self.submenus: List[str] = []
+
+ @contextlib.contextmanager
+ def menu(self, **kwargs: Any) -> Iterator[None]:
+ self.submenus.append(kwargs["label"])
+ yield
+
+ def add_menu_item(self, **kwargs: Any) -> int:
+ self.items.append(
+ MenuItem(
+ label=kwargs["label"],
+ shortcut=kwargs.get("shortcut", ""),
+ enabled=kwargs.get("enabled", True),
+ callback=kwargs.get("callback", _unreachable),
+ )
+ )
+ return 0
+
+
+@pytest.fixture
+def recorder(monkeypatch: pytest.MonkeyPatch) -> _MenuRecorder:
+ recorded = _MenuRecorder()
+ monkeypatch.setattr(menu_module.dpg, "add_menu_item", recorded.add_menu_item)
+ monkeypatch.setattr(menu_module.dpg, "add_separator", lambda **_kwargs: 0)
+ monkeypatch.setattr(menu_module.dpg, "menu", recorded.menu)
+ return recorded
+
+
+@dataclass
+class VoicesPanelFixture:
+ """A panel holding a selection, with the calls each menu item makes recorded."""
+
+ panel: GUISequencerVoicesPanel
+ menu: VoicesMenu
+ requests: Requests
+
+
+def _panel(
+ monkeypatch: pytest.MonkeyPatch,
+ *,
+ selected_row: Optional[int] = SELECTED_ROW,
+ tab_active: bool = True,
+ editing: Optional[str] = None,
+ field_focused: bool = False,
+ footprint: Optional[VoiceFootprintViewModel] = FOOTPRINT,
+ footprint_wired: bool = True,
+ instruments: Tuple[Optional[ChannelName], ...] = ONE_INSTRUMENT,
+ channels: Tuple[ChannelName, ...] = NO_CHANNELS,
+) -> VoicesPanelFixture:
+ """A voices panel whose menu builder can run with no DearPyGui context behind it.
+
+ Each action stands as a single item by default — one instrument to export and no channel to
+ take — so a case naming a position names the same one however the voice is stocked.
+ """
+ panel = GUISequencerVoicesPanel.__new__(GUISequencerVoicesPanel)
+ panel._language_manager = _Labels()
+ panel._shortcuts = shipped_source()
+ panel._entries = ENTRIES
+ panel._selected_voice_id = None if selected_row is None else SELECTED_ID
+ panel._selected_row = selected_row
+ panel._editing_voice_id = editing
+ panel._list_menu_pending = False
+ panel._tab_active = lambda: tab_active
+ panel._router = _Router(field_focused=field_focused)
+ panel.voice_footprint = (lambda _voice_id: footprint) if footprint_wired else None
+ panel.voice_instruments = lambda _voice_id: instruments
+ panel.instrument_channels = lambda _voice_id: channels
+
+ requests = Requests()
+ panel.on_voice_edit_requested = requests.edited.append
+ panel.on_duplicate_requested = requests.duplicated.append
+ panel.on_remove_requested = requests.removed.append
+ panel.on_move_requested = lambda voice_id, target: requests.moved.append((voice_id, target))
+ panel.on_new_instrument_requested = lambda: requests.pool.append(SequencerVoicesElements.NEW_INSTRUMENT.value)
+ panel.on_add_sample_requested = lambda: requests.pool.append(SequencerVoicesElements.ADD_SAMPLE.value)
+ panel.on_import_instrument_requested = lambda: requests.pool.append(SequencerVoicesElements.IMPORT_INSTRUMENT.value)
+ panel.on_export_instrument_requested = lambda voice_id, channel: requests.exported.append((voice_id, channel))
+ panel.on_instrument_from_channel_requested = lambda voice_id, channel: requests.taken.append((voice_id, channel))
+ monkeypatch.setattr(panel, "start_rename", requests.renamed.append)
+
+ menu = VoicesMenu(
+ panel,
+ language_manager=_Labels(),
+ shortcut_source=shipped_source(),
+ detail_color=DETAIL_COLOR,
+ )
+ menu._footprint_text._lbl_sample_size = SAMPLE_SIZE_LABEL
+ menu._footprint_text._tpl_size_bytes = SIZE_TEMPLATE
+ menu._tip_size_bytes = SIZE_TOOLTIP
+ panel._menu = menu
+ return VoicesPanelFixture(panel=panel, menu=menu, requests=requests)
+
+
+class _Labels:
+ """A language manager printing each key's own element, so an item reads as the action it names."""
+
+ def __getitem__(self, key: Tuple[Any, ...]) -> str:
+ return str(key[-1].value)
+
+
+@dataclass(frozen=True)
+class MenuWidget:
+ """One widget as the menu registered it, which is the whole of what a reader meets."""
+
+ kind: str
+ text: str
+
+
+class _MenuBuildRecorder:
+ """Every widget a whole menu build registers, in the order they are printed."""
+
+ def __init__(self) -> None:
+ self.widgets: List[MenuWidget] = []
+ self.tooltips: List[str] = []
+
+ def add_text(self, text: str, **_kwargs: Any) -> int:
+ self.widgets.append(MenuWidget(kind="text", text=text))
+ return 0
+
+ def add_tooltip(self, _parent: int, message: str, **_kwargs: Any) -> int:
+ self.tooltips.append(message)
+ return 0
+
+ def add_separator(self, **_kwargs: Any) -> int:
+ self.widgets.append(MenuWidget(kind="separator", text=""))
+ return 0
+
+ def add_menu_item(self, **kwargs: Any) -> int:
+ self.widgets.append(MenuWidget(kind="item", text=kwargs["label"]))
+ return 0
+
+ @contextlib.contextmanager
+ def menu(self, **kwargs: Any) -> Iterator[None]:
+ self.widgets.append(MenuWidget(kind="menu", text=kwargs["label"]))
+ yield
+
+ def texts_before_the_first_item(self) -> List[str]:
+ widgets: List[str] = []
+ for widget in self.widgets:
+ if widget.kind == "item":
+ break
+ if widget.kind == "text":
+ widgets.append(widget.text)
+
+ return widgets
+
+
+@contextlib.contextmanager
+def _null_menu() -> Iterator[None]:
+ yield
+
+
+def _deferred_calls(monkeypatch: pytest.MonkeyPatch) -> List[VoidCallback]:
+ """The callbacks handed to the next frame, which is where the list's menu waits."""
+ deferred: List[VoidCallback] = []
+ monkeypatch.setattr(
+ panel_module.FrameCallbackManager,
+ "set_frame_callback",
+ lambda callback: deferred.append(callback),
+ )
+ return deferred
+
+
+@pytest.fixture
+def build_recorder(monkeypatch: pytest.MonkeyPatch) -> _MenuBuildRecorder:
+ """Records a whole context-menu build, with the DearPyGui calls behind it stood down."""
+ recorded = _MenuBuildRecorder()
+ monkeypatch.setattr(menu_module.dpg, "add_text", recorded.add_text)
+ monkeypatch.setattr(menu_module.dpg, "add_separator", recorded.add_separator)
+ monkeypatch.setattr(menu_module.dpg, "add_menu_item", recorded.add_menu_item)
+ monkeypatch.setattr(menu_module.dpg, "menu", recorded.menu)
+ monkeypatch.setattr(menu_module, "context_menu", _null_menu)
+ monkeypatch.setattr(context_menu_module, "dpg_set_palette_color", lambda _item, _color: None)
+ monkeypatch.setattr(context_menu_module, "show_tooltip", recorded.add_tooltip)
+ monkeypatch.setattr(FontRegistry, "bind_to_item", lambda _item, _font: None)
+ return recorded
+
+
+@dataclass(frozen=True)
+class _Router:
+ """The key router as the panel's own scope reads it."""
+
+ field_focused: bool
+
+ @property
+ def is_field_focused(self) -> bool:
+ return self.field_focused
+
+
+class TestActionItems:
+ def test_the_menu_reads_as_the_voice_actions(
+ self,
+ monkeypatch: pytest.MonkeyPatch,
+ recorder: _MenuRecorder,
+ ) -> None:
+ _panel(monkeypatch).panel.build_edit_actions()
+
+ assert [item.label for item in recorder.items] == [
+ SequencerVoicesElements.CONTEXT_EDIT.value,
+ SequencerVoicesElements.CONTEXT_RENAME.value,
+ SequencerVoicesElements.CONTEXT_DUPLICATE.value,
+ SequencerVoicesElements.CONTEXT_INSTRUMENT_FROM.value,
+ SequencerVoicesElements.CONTEXT_REMOVE.value,
+ *(move.element.value for move in VOICE_MOVES),
+ SequencerVoicesElements.CONTEXT_EXPORT_INSTRUMENT.value,
+ ]
+
+ def test_the_items_print_the_keys_the_panel_answers_to(
+ self,
+ monkeypatch: pytest.MonkeyPatch,
+ recorder: _MenuRecorder,
+ ) -> None:
+ """The panel has always answered these presses, and an item prints the one it fires."""
+ shortcuts = shipped_source()
+ _panel(monkeypatch).panel.build_edit_actions()
+
+ assert recorder.items[RENAME_ITEM].shortcut == shortcuts.display(ShortcutId.VOICES_RENAME_VOICE)
+ assert recorder.items[REMOVE_ITEM].shortcut == shortcuts.display(ShortcutId.VOICES_REMOVE_VOICE)
+ assert [item.shortcut for item in recorder.items[MOVE_UP_ITEM : MOVE_BOTTOM_ITEM + 1]] == [
+ shortcuts.display(move.shortcut) for move in VOICE_MOVES
+ ]
+
+ def test_the_items_act_on_the_voice_they_were_raised_on(
+ self,
+ monkeypatch: pytest.MonkeyPatch,
+ recorder: _MenuRecorder,
+ ) -> None:
+ fixture = _panel(monkeypatch)
+ fixture.panel.build_edit_actions()
+
+ for item in recorder.items:
+ item.callback()
+
+ assert fixture.requests.edited == [SELECTED_ID]
+ assert fixture.requests.renamed == [SELECTED_ID]
+ assert fixture.requests.duplicated == [SELECTED_ID]
+ assert fixture.requests.removed == [SELECTED_ID]
+ assert fixture.requests.moved == [
+ (SELECTED_ID, SELECTED_ROW - 1),
+ (SELECTED_ID, SELECTED_ROW + 1),
+ (SELECTED_ID, 0),
+ (SELECTED_ID, len(ENTRIES) - 1),
+ ]
+ assert fixture.requests.exported == [(SELECTED_ID, ChannelName.PULSE1)]
+
+ def test_a_move_with_nowhere_to_go_is_grayed_out(
+ self,
+ monkeypatch: pytest.MonkeyPatch,
+ recorder: _MenuRecorder,
+ ) -> None:
+ _panel(monkeypatch, selected_row=0).panel.build_edit_actions()
+
+ assert not recorder.items[MOVE_UP_ITEM].enabled
+ assert not recorder.items[MOVE_TOP_ITEM].enabled
+ assert recorder.items[MOVE_DOWN_ITEM].enabled
+ assert recorder.items[MOVE_BOTTOM_ITEM].enabled
+
+
+class TestTakingAChannelAsAnInstrument:
+ """A recording's channel becomes a voice of envelopes, so the menu names the channels it holds."""
+
+ def test_every_channel_that_plays_is_offered(
+ self,
+ monkeypatch: pytest.MonkeyPatch,
+ recorder: _MenuRecorder,
+ ) -> None:
+ _panel(monkeypatch, channels=TWO_CHANNELS).panel.build_edit_actions()
+
+ assert recorder.submenus == [SequencerVoicesElements.CONTEXT_INSTRUMENT_FROM.value]
+ assert [item.label for item in recorder.items[INSTRUMENT_FROM_ITEM : INSTRUMENT_FROM_ITEM + 2]] == [
+ ContextElements.PULSE_1.value,
+ ContextElements.NOISE.value,
+ ]
+
+ def test_a_single_channel_still_says_which_one_it_is(
+ self,
+ monkeypatch: pytest.MonkeyPatch,
+ recorder: _MenuRecorder,
+ ) -> None:
+ """What the new voice plays is the channel it came from, so the channel is always named."""
+ _panel(monkeypatch, channels=ONE_CHANNEL).panel.build_edit_actions()
+
+ assert recorder.submenus == [SequencerVoicesElements.CONTEXT_INSTRUMENT_FROM.value]
+ assert recorder.items[INSTRUMENT_FROM_ITEM].label == ContextElements.TRIANGLE.value
+
+ def test_each_channel_asks_for_the_instrument_it_names(
+ self,
+ monkeypatch: pytest.MonkeyPatch,
+ recorder: _MenuRecorder,
+ ) -> None:
+ fixture = _panel(monkeypatch, channels=TWO_CHANNELS)
+ fixture.panel.build_edit_actions()
+
+ for item in recorder.items[INSTRUMENT_FROM_ITEM : INSTRUMENT_FROM_ITEM + 2]:
+ item.callback()
+
+ assert fixture.requests.taken == [
+ (SELECTED_ID, ChannelName.PULSE1),
+ (SELECTED_ID, ChannelName.NOISE),
+ ]
+
+ def test_a_voice_already_written_as_envelopes_offers_an_item_it_cannot_reach(
+ self,
+ monkeypatch: pytest.MonkeyPatch,
+ recorder: _MenuRecorder,
+ ) -> None:
+ """It is what this would make of it, so the action stands named and out of reach."""
+ fixture = _panel(monkeypatch, channels=NO_CHANNELS)
+ fixture.panel.build_edit_actions()
+
+ assert not recorder.items[INSTRUMENT_FROM_ITEM].enabled
+ assert fixture.requests.taken == []
+
+
+class TestExportingTheVoicesInstruments:
+ """The menu offers what an export would write for the voice, however many instruments that is."""
+
+ def test_a_voice_holding_one_instrument_offers_a_plain_item(
+ self,
+ monkeypatch: pytest.MonkeyPatch,
+ recorder: _MenuRecorder,
+ ) -> None:
+ """There is nothing to choose between, so the item writes the one instrument straight away."""
+ _panel(monkeypatch).panel.build_edit_actions()
+
+ assert recorder.submenus == []
+ assert recorder.items[EXPORT_ITEM].label == SequencerVoicesElements.CONTEXT_EXPORT_INSTRUMENT.value
+
+ def test_a_voice_holding_several_offers_one_item_per_channel(
+ self,
+ monkeypatch: pytest.MonkeyPatch,
+ recorder: _MenuRecorder,
+ ) -> None:
+ _panel(monkeypatch, instruments=TWO_INSTRUMENTS).panel.build_edit_actions()
+
+ assert recorder.submenus == [SequencerVoicesElements.CONTEXT_EXPORT_INSTRUMENT.value]
+ assert [item.label for item in recorder.items[EXPORT_ITEM:]] == [
+ ContextElements.PULSE_1.value,
+ ContextElements.NOISE.value,
+ ]
+
+ def test_each_channel_writes_the_instrument_it_names(
+ self,
+ monkeypatch: pytest.MonkeyPatch,
+ recorder: _MenuRecorder,
+ ) -> None:
+ fixture = _panel(monkeypatch, instruments=TWO_INSTRUMENTS)
+ fixture.panel.build_edit_actions()
+
+ for item in recorder.items[EXPORT_ITEM:]:
+ item.callback()
+
+ assert fixture.requests.exported == [
+ (SELECTED_ID, ChannelName.PULSE1),
+ (SELECTED_ID, ChannelName.NOISE),
+ ]
+
+ def test_an_instrument_stated_for_no_channel_is_named_after_its_voice(
+ self,
+ monkeypatch: pytest.MonkeyPatch,
+ recorder: _MenuRecorder,
+ ) -> None:
+ """A hand-written voice reads the same envelopes on every channel, so it carries its own name."""
+ _panel(monkeypatch, instruments=(None, ChannelName.NOISE)).panel.build_edit_actions()
+
+ assert recorder.items[EXPORT_ITEM].label == "Bass"
+
+ def test_a_voice_writing_nothing_offers_an_item_it_cannot_reach(
+ self,
+ monkeypatch: pytest.MonkeyPatch,
+ recorder: _MenuRecorder,
+ ) -> None:
+ """The export exists for every voice, and this one has nothing yet to write."""
+ fixture = _panel(monkeypatch, instruments=NO_INSTRUMENTS)
+ fixture.panel.build_edit_actions()
+
+ assert not recorder.items[EXPORT_ITEM].enabled
+ assert fixture.requests.exported == []
+
+
+class TestTheSizeRows:
+ """A sample's menu names the bytes it occupies, so what a pool costs is read where it is edited."""
+
+ def test_the_rows_read_as_the_total_then_each_playing_channel(self, monkeypatch: pytest.MonkeyPatch) -> None:
+ items = _panel(monkeypatch).menu._footprint_items(SELECTED_ID)
+
+ assert items == [
+ (SAMPLE_SIZE_LABEL, f"{PULSE_1_BYTES + NOISE_BYTES} B"),
+ (ContextElements.PULSE_1.value, f"{PULSE_1_BYTES} B"),
+ (ContextElements.NOISE.value, f"{NOISE_BYTES} B"),
+ ]
+
+ def test_a_channel_standing_by_is_named_nowhere(self, monkeypatch: pytest.MonkeyPatch) -> None:
+ """A channel that does not play is written by no export, so it costs nothing to name."""
+ labels = [label for label, _value in _panel(monkeypatch).menu._footprint_items(SELECTED_ID)]
+
+ assert ContextElements.PULSE_2.value not in labels
+ assert ContextElements.TRIANGLE.value not in labels
+
+ def test_the_figures_name_the_voice_the_pointer_landed_on(self, monkeypatch: pytest.MonkeyPatch) -> None:
+ """The figures are asked for as the menu opens, so they answer for the row right-clicked."""
+ measured: List[str] = []
+
+ def _measure(voice_id: str) -> VoiceFootprintViewModel:
+ measured.append(voice_id)
+ return FOOTPRINT
+
+ fixture = _panel(monkeypatch)
+ fixture.panel.voice_footprint = _measure
+
+ fixture.menu._footprint_items("lead-id")
+
+ assert measured == ["lead-id"]
+
+ def test_a_voice_the_pool_has_dropped_prints_no_rows(self, monkeypatch: pytest.MonkeyPatch) -> None:
+ assert _panel(monkeypatch, footprint=None).menu._footprint_items(SELECTED_ID) == []
+
+ def test_an_unwired_hook_prints_no_rows(self, monkeypatch: pytest.MonkeyPatch) -> None:
+ """A panel tolerates its hooks being unset until the coordinator wires them."""
+ assert _panel(monkeypatch, footprint_wired=False).menu._footprint_items(SELECTED_ID) == []
+
+
+class TestMenuComposition:
+ def test_the_sizes_sit_between_the_voice_name_and_the_actions(
+ self,
+ monkeypatch: pytest.MonkeyPatch,
+ build_recorder: _MenuBuildRecorder,
+ ) -> None:
+ """Pins where the figures are printed: under the name they belong to, above what can be done."""
+ _panel(monkeypatch).panel._show_context_menu(SELECTED_ROW, SELECTED_ID)
+
+ assert build_recorder.texts_before_the_first_item() == [
+ display_voice_label(SELECTED_ROW, "Bass"),
+ f"{SAMPLE_SIZE_LABEL}: {PULSE_1_BYTES + NOISE_BYTES} B",
+ f"{ContextElements.PULSE_1.value}: {PULSE_1_BYTES} B",
+ f"{ContextElements.NOISE.value}: {NOISE_BYTES} B",
+ ]
+
+ def test_every_figure_names_the_export_it_measures(
+ self,
+ monkeypatch: pytest.MonkeyPatch,
+ build_recorder: _MenuBuildRecorder,
+ ) -> None:
+ """A byte count means one export, so each line a reader hovers says which one it counts."""
+ _panel(monkeypatch).panel._show_context_menu(SELECTED_ROW, SELECTED_ID)
+
+ assert build_recorder.tooltips == [SIZE_TOOLTIP] * 3
+
+ def test_a_menu_with_no_figures_reads_as_it_always_has(
+ self,
+ monkeypatch: pytest.MonkeyPatch,
+ build_recorder: _MenuBuildRecorder,
+ ) -> None:
+ _panel(monkeypatch, footprint=None).panel._show_context_menu(SELECTED_ROW, SELECTED_ID)
+
+ assert build_recorder.texts_before_the_first_item() == [display_voice_label(SELECTED_ROW, "Bass")]
+
+ def test_a_row_the_list_no_longer_holds_raises_nothing(
+ self,
+ monkeypatch: pytest.MonkeyPatch,
+ build_recorder: _MenuBuildRecorder,
+ ) -> None:
+ _panel(monkeypatch).panel._show_context_menu(SELECTED_ROW, "gone")
+
+ assert build_recorder.widgets == []
+
+
+class TestEditActions:
+ def test_the_panel_answers_while_it_holds_a_selection(self, monkeypatch: pytest.MonkeyPatch) -> None:
+ assert _panel(monkeypatch).panel.owns_edit_actions()
+
+ def test_a_panel_holding_no_selection_stands_down(self, monkeypatch: pytest.MonkeyPatch) -> None:
+ """The grids and this panel hold one selection between them, so one of them answers."""
+ assert not _panel(monkeypatch, selected_row=None).panel.owns_edit_actions()
+
+ def test_a_panel_on_a_tab_behind_stands_down(self, monkeypatch: pytest.MonkeyPatch) -> None:
+ """A selection outlives a move to another tab, and the Edit menu follows the tab in front."""
+ assert not _panel(monkeypatch, tab_active=False).panel.owns_edit_actions()
+
+ def test_a_field_holding_the_keyboard_stands_the_panel_down(self, monkeypatch: pytest.MonkeyPatch) -> None:
+ assert not _panel(monkeypatch, field_focused=True).panel.owns_edit_actions()
+
+ def test_a_panel_holding_no_selection_builds_nothing(
+ self,
+ monkeypatch: pytest.MonkeyPatch,
+ recorder: _MenuRecorder,
+ ) -> None:
+ _panel(monkeypatch, selected_row=None).panel.build_edit_actions()
+
+ assert recorder.items == []
+
+
+class TestVoiceMenuActions:
+ """The menu bar's Voice group carries the same actions the row menu prints."""
+
+ def test_the_chosen_voice_states_its_actions_under_a_rule(
+ self,
+ monkeypatch: pytest.MonkeyPatch,
+ build_recorder: _MenuBuildRecorder,
+ ) -> None:
+ """The group lists the pool above them, so the voice's own actions are led by a divider."""
+ _panel(monkeypatch).panel.build_voice_actions()
+
+ kinds = [widget.kind for widget in build_recorder.widgets]
+ items = [widget.text for widget in build_recorder.widgets if widget.kind == "item"]
+
+ assert kinds[0] == "separator"
+ assert items[0] == SequencerVoicesElements.CONTEXT_EDIT.value
+
+ def test_no_voice_chosen_states_nothing_at_all(
+ self,
+ monkeypatch: pytest.MonkeyPatch,
+ build_recorder: _MenuBuildRecorder,
+ ) -> None:
+ """A group with no voice to act on shows the ways one comes in, and no divider below."""
+ _panel(monkeypatch, selected_row=None).panel.build_voice_actions()
+
+ assert build_recorder.widgets == []
+
+
+class TestThePoolItems:
+ """Every door onto the list offers the ways a voice comes in, so adding one is never hidden."""
+
+ def test_the_list_menu_prints_the_ways_a_voice_comes_in(
+ self,
+ monkeypatch: pytest.MonkeyPatch,
+ build_recorder: _MenuBuildRecorder,
+ ) -> None:
+ fixture = _panel(monkeypatch)
+ fixture.panel._list_menu_pending = True
+
+ fixture.panel._show_list_menu()
+
+ assert [widget.text for widget in build_recorder.widgets] == [
+ SequencerVoicesElements.NEW_INSTRUMENT.value,
+ SequencerVoicesElements.ADD_SAMPLE.value,
+ SequencerVoicesElements.IMPORT_INSTRUMENT.value,
+ ]
+
+ def test_a_row_menu_carries_the_pool_section_below_the_voice_actions(
+ self,
+ monkeypatch: pytest.MonkeyPatch,
+ build_recorder: _MenuBuildRecorder,
+ ) -> None:
+ """A row is where a reader already is, so the list's own offers stay within reach there."""
+ _panel(monkeypatch).panel._show_context_menu(SELECTED_ROW, SELECTED_ID)
+
+ items = [widget.text for widget in build_recorder.widgets if widget.kind == "item"]
+
+ assert items[-3:] == [
+ SequencerVoicesElements.NEW_INSTRUMENT.value,
+ SequencerVoicesElements.ADD_SAMPLE.value,
+ SequencerVoicesElements.IMPORT_INSTRUMENT.value,
+ ]
+ assert SequencerVoicesElements.CONTEXT_EDIT.value in items
+
+ def test_each_item_prints_the_key_it_answers_to(
+ self,
+ monkeypatch: pytest.MonkeyPatch,
+ recorder: _MenuRecorder,
+ ) -> None:
+ """Every way a voice comes in is rebindable, so each item names the press that fires it."""
+ shortcuts = shipped_source()
+ _panel(monkeypatch).menu.add_pool_items()
+
+ assert [item.shortcut for item in recorder.items] == [
+ shortcuts.display(ShortcutId.NEW_INSTRUMENT),
+ shortcuts.display(ShortcutId.ADD_SAMPLE_FROM_FILE),
+ shortcuts.display(ShortcutId.IMPORT_INSTRUMENT),
+ ]
+
+ def test_the_items_ask_for_a_written_voice_and_for_a_located_one(
+ self,
+ monkeypatch: pytest.MonkeyPatch,
+ recorder: _MenuRecorder,
+ ) -> None:
+ fixture = _panel(monkeypatch)
+ fixture.menu.add_pool_items()
+
+ for item in recorder.items:
+ item.callback()
+
+ assert fixture.requests.pool == [
+ SequencerVoicesElements.NEW_INSTRUMENT.value,
+ SequencerVoicesElements.ADD_SAMPLE.value,
+ SequencerVoicesElements.IMPORT_INSTRUMENT.value,
+ ]
+
+
+class TestWhichDoorAnswersAPress:
+ """The list and the row are offered the same press, the list first, so one of them answers it."""
+
+ def test_a_press_on_the_list_holds_its_menu_for_a_frame(
+ self,
+ monkeypatch: pytest.MonkeyPatch,
+ ) -> None:
+ fixture = _panel(monkeypatch)
+ deferred = _deferred_calls(monkeypatch)
+ monkeypatch.setattr(fixture.panel, "_pointer_within_list", lambda: True)
+
+ fixture.panel._on_list_right_clicked(0, RIGHT_BUTTON)
+
+ assert fixture.panel._list_menu_pending
+ assert deferred == [fixture.panel._show_list_menu]
+
+ def test_a_row_claiming_the_press_leaves_the_list_menu_unbuilt(
+ self,
+ monkeypatch: pytest.MonkeyPatch,
+ build_recorder: _MenuBuildRecorder,
+ ) -> None:
+ fixture = _panel(monkeypatch)
+ _deferred_calls(monkeypatch)
+ monkeypatch.setattr(fixture.panel, "_pointer_within_list", lambda: True)
+ monkeypatch.setattr(fixture.panel, "_show_context_menu", lambda _position, _voice_id: None)
+ monkeypatch.setattr(panel_module.dpg, "get_item_user_data", lambda _item: (SELECTED_ROW, SELECTED_ID))
+
+ fixture.panel._on_list_right_clicked(0, RIGHT_BUTTON)
+ fixture.panel._on_voice_clicked(0, (RIGHT_BUTTON, 0))
+ fixture.panel._show_list_menu()
+
+ assert build_recorder.widgets == []
+
+ def test_a_press_beyond_the_list_asks_for_no_menu(
+ self,
+ monkeypatch: pytest.MonkeyPatch,
+ ) -> None:
+ fixture = _panel(monkeypatch)
+ deferred = _deferred_calls(monkeypatch)
+ monkeypatch.setattr(fixture.panel, "_pointer_within_list", lambda: False)
+
+ fixture.panel._on_list_right_clicked(0, RIGHT_BUTTON)
+
+ assert not fixture.panel._list_menu_pending
+ assert deferred == []
diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/test_samples_selection.py b/tests/unit/sampletones_application/ui/panels/sequencer/voices/test_selection.py
similarity index 59%
rename from tests/unit/sampletones_application/ui/panels/sequencer/test_samples_selection.py
rename to tests/unit/sampletones_application/ui/panels/sequencer/voices/test_selection.py
index e787b42a3..803e414d3 100644
--- a/tests/unit/sampletones_application/ui/panels/sequencer/test_samples_selection.py
+++ b/tests/unit/sampletones_application/ui/panels/sequencer/voices/test_selection.py
@@ -1,27 +1,27 @@
from typing import Optional, Tuple
-from sampletones_application.ui.panels.sequencer.samples import GUISequencerSamplesPanel
-from sampletones_application.view_model.sequencer.samples import SampleEntryViewModel
+from sampletones_application.ui.panels.sequencer.voices.panel import GUISequencerVoicesPanel
+from sampletones_application.view_model.sequencer.voices import VoiceEntryViewModel, VoiceKind
-ENTRIES: Tuple[SampleEntryViewModel, ...] = (
- SampleEntryViewModel(sample_id="kick-id", name="Kick", loop=False),
- SampleEntryViewModel(sample_id="bass-id", name="Bass", loop=True),
+ENTRIES: Tuple[VoiceEntryViewModel, ...] = (
+ VoiceEntryViewModel(voice_id="kick-id", name="Kick", kind=VoiceKind.SAMPLE),
+ VoiceEntryViewModel(voice_id="bass-id", name="Bass", kind=VoiceKind.SAMPLE),
)
def _panel(
- selected_sample_id: Optional[str],
+ selected_voice_id: Optional[str],
selected_row: Optional[int],
- entries: Tuple[SampleEntryViewModel, ...] = ENTRIES,
-) -> GUISequencerSamplesPanel:
+ entries: Tuple[VoiceEntryViewModel, ...] = ENTRIES,
+) -> GUISequencerVoicesPanel:
"""Builds a panel without its DearPyGui-dependent constructor.
The selection accessor reads only the cached entries and the highlighted row, so a running
GUI context is unnecessary here.
"""
- panel = GUISequencerSamplesPanel.__new__(GUISequencerSamplesPanel)
+ panel = GUISequencerVoicesPanel.__new__(GUISequencerVoicesPanel)
panel._entries = entries
- panel._selected_sample_id = selected_sample_id
+ panel._selected_voice_id = selected_voice_id
panel._selected_row = selected_row
return panel
@@ -33,7 +33,7 @@ def test_reports_the_highlighted_sample(self) -> None:
selection = panel.selection
assert selection is not None
- assert selection.sample_id == "bass-id"
+ assert selection.voice_id == "bass-id"
assert selection.position == 1
assert selection.name == "Bass"
assert selection.label == "01: Bass"
@@ -48,7 +48,7 @@ def test_absent_once_the_selected_sample_leaves_the_pool(self) -> None:
def test_follows_a_renamed_sample(self) -> None:
panel = _panel("kick-id", 0)
- panel._entries = (SampleEntryViewModel(sample_id="kick-id", name="Thump", loop=False),)
+ panel._entries = (VoiceEntryViewModel(voice_id="kick-id", name="Thump", kind=VoiceKind.SAMPLE),)
selection = panel.selection
diff --git a/tests/unit/sampletones_application/ui/test_menu.py b/tests/unit/sampletones_application/ui/test_menu.py
index a040aac83..46032aaec 100644
--- a/tests/unit/sampletones_application/ui/test_menu.py
+++ b/tests/unit/sampletones_application/ui/test_menu.py
@@ -9,12 +9,16 @@
from sampletones_application.tags.general import (
TAG_GLOBAL_MENU_GROUP_EDIT,
TAG_GLOBAL_MENU_GROUP_EDIT_MARKER,
+ TAG_GLOBAL_MENU_GROUP_VOICE,
+ TAG_GLOBAL_MENU_GROUP_VOICE_MARKER,
TAG_GLOBAL_MENU_ITEM_PLAYBACK_UNMUTE_ALL_CHANNELS,
TAG_GLOBAL_MENU_ITEM_RECONSTRUCTION_EXPORT_INSTRUMENTS,
TAG_GLOBAL_MENU_ITEM_VIEW_AUTO_EXPAND_FAVORITE_DIRECTORIES,
TAG_GLOBAL_MENU_ITEM_VIEW_AUTO_EXPAND_FAVORITE_RECONSTRUCTIONS,
)
from sampletones_application.ui import menu as menu_module
+from sampletones_application.ui.elements import menu_section as menu_section_module
+from sampletones_application.ui.elements.menu_section import MenuSection
from sampletones_application.ui.menu import MenuBar
from sampletones_application.utils.gui.shortcuts.ids import (
CHANNEL_SHORTCUT_IDS,
@@ -26,7 +30,7 @@
SequencerChannelsViewModel,
)
from sampletones_application.view_model.shared.menu import MenuBarViewModel
-from sampletones_core.constants.enums import GeneratorName
+from sampletones_core.constants.enums import ChannelName
CHANNEL_NAMES = ["Pulse 1", "Pulse 2", "Triangle", "Noise"]
UNMUTE_ALL = "Unmute all channels"
@@ -117,7 +121,7 @@ def configure_item(self, item: str, **kwargs: Any) -> None:
def _state(
- muted: FrozenSet[GeneratorName],
+ muted: FrozenSet[ChannelName],
*,
reconstruction_loaded: bool = False,
follow_mode: FollowMode = FollowMode.OFF,
@@ -164,8 +168,12 @@ def framework(monkeypatch: pytest.MonkeyPatch) -> _DearPyGuiRecorder:
monkeypatch.setattr(menu_module.dpg, "bind_item_handler_registry", instance.bind_item_handler_registry)
monkeypatch.setattr(menu_module, "dpg_set_value", instance.set_value)
monkeypatch.setattr(menu_module, "dpg_configure_item", instance.configure_item)
- monkeypatch.setattr(menu_module, "dpg_append_items", instance.append_items)
- monkeypatch.setattr(menu_module, "dpg_delete_item", instance.delete_item)
+ monkeypatch.setattr(menu_section_module.dpg, "add_group", instance.add_group)
+ monkeypatch.setattr(menu_section_module.dpg, "item_handler_registry", instance.item_handler_registry)
+ monkeypatch.setattr(menu_section_module.dpg, "add_item_visible_handler", instance.add_item_visible_handler)
+ monkeypatch.setattr(menu_section_module.dpg, "bind_item_handler_registry", instance.bind_item_handler_registry)
+ monkeypatch.setattr(menu_section_module, "dpg_append_items", instance.append_items)
+ monkeypatch.setattr(menu_section_module, "dpg_delete_item", instance.delete_item)
return instance
@@ -175,7 +183,13 @@ def shortcuts(framework: _DearPyGuiRecorder) -> _ShortcutManagerRecorder:
@pytest.fixture
-def switched() -> List[GeneratorName]:
+def voice_actions() -> Callable[[], None]:
+ """Stands in for the voices panel, which states the chosen voice's actions."""
+ return lambda: None
+
+
+@pytest.fixture
+def switched() -> List[ChannelName]:
"""The channels the bar asks the sequencer to switch, in the order it asks."""
return []
@@ -183,7 +197,8 @@ def switched() -> List[GeneratorName]:
@pytest.fixture
def menu_bar(
shortcuts: _ShortcutManagerRecorder,
- switched: List[GeneratorName],
+ switched: List[ChannelName],
+ voice_actions: Callable[[], None],
) -> MenuBar:
"""A bar with the collaborators its submenus read, from the real language file."""
instance = MenuBar.__new__(MenuBar)
@@ -191,17 +206,24 @@ def menu_bar(
instance._language_manager = LanguageManager(LANG_EN)
instance._on_channel_muted = switched.append
instance._build_edit_actions = lambda: False
- instance._edit_actions_handler_tag = "handlers"
- instance._edit_actions_frame = None
- instance._edit_action_items = ()
+ instance._edit_section = MenuSection(
+ menu_tag=TAG_GLOBAL_MENU_GROUP_EDIT,
+ marker_tag=TAG_GLOBAL_MENU_GROUP_EDIT_MARKER,
+ build=instance._add_edit_action_items,
+ )
+ instance._voice_section = MenuSection(
+ menu_tag=TAG_GLOBAL_MENU_GROUP_VOICE,
+ marker_tag=TAG_GLOBAL_MENU_GROUP_VOICE_MARKER,
+ build=voice_actions,
+ )
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."""
+ """Each offered format gets its own item, so choosing the format is one click and the
+ destination dialog then offers that format's type alone."""
- def test_every_offered_tracker_is_listed(
+ def test_every_offered_format_is_listed(
self,
menu_bar: MenuBar,
framework: _DearPyGuiRecorder,
@@ -214,6 +236,7 @@ def test_every_offered_tracker_is_listed(
assert [entry["label"] for entry in entries] == [
"FamiTracker instruments...",
"Bitphase presets...",
+ "NSF program...",
]
def test_the_submenu_waits_for_a_loaded_reconstruction(
@@ -328,7 +351,7 @@ def test_choosing_a_channel_switches_the_sequencer_mix(
menu_bar: MenuBar,
shortcuts: _ShortcutManagerRecorder,
framework: _DearPyGuiRecorder,
- switched: List[GeneratorName],
+ switched: List[ChannelName],
) -> None:
"""The check beside an item names the sequencer's mix, so the item switches that mix
wherever the reader stands, while the key printed beside it reads the tab in front."""
@@ -348,7 +371,7 @@ def test_each_channel_carries_its_own_tag(
menu_bar._create_channels_menu(_state(frozenset()))
tags = [item["tag"] for item in shortcuts.items[:-1]]
- assert tags == [MenuBar._channel_menu_item_tag(generator) for generator in CHANNEL_SHORTCUT_IDS]
+ assert tags == [MenuBar._channel_menu_item_tag(channel) for channel in CHANNEL_SHORTCUT_IDS]
def test_a_channel_is_checked_while_it_sounds(
self,
@@ -356,7 +379,7 @@ def test_a_channel_is_checked_while_it_sounds(
shortcuts: _ShortcutManagerRecorder,
framework: _DearPyGuiRecorder,
) -> None:
- menu_bar._create_channels_menu(_state(frozenset({GeneratorName.TRIANGLE})))
+ menu_bar._create_channels_menu(_state(frozenset({ChannelName.TRIANGLE})))
assert shortcuts.item("Pulse 1")["default_value"]
assert not shortcuts.item("Triangle")["default_value"]
@@ -365,7 +388,7 @@ def test_a_channel_is_checked_while_it_sounds(
("muted", "offered"),
[
(frozenset(), False),
- (frozenset({GeneratorName.NOISE}), True),
+ (frozenset({ChannelName.NOISE}), True),
],
ids=["full_mix_withholds_the_restore", "a_silenced_channel_offers_the_restore"],
)
@@ -374,7 +397,7 @@ def test_the_restore_is_offered_while_a_channel_is_silenced(
menu_bar: MenuBar,
shortcuts: _ShortcutManagerRecorder,
framework: _DearPyGuiRecorder,
- muted: FrozenSet[GeneratorName],
+ muted: FrozenSet[ChannelName],
offered: bool,
) -> None:
menu_bar._create_channels_menu(_state(muted))
@@ -388,13 +411,13 @@ def test_the_checks_follow_the_mute_set(
menu_bar: MenuBar,
framework: _DearPyGuiRecorder,
) -> None:
- menu_bar._update_channels(_state(frozenset({GeneratorName.PULSE2})))
+ menu_bar._update_channels(_state(frozenset({ChannelName.PULSE2})))
assert framework.values == {
- MenuBar._channel_menu_item_tag(GeneratorName.PULSE1): True,
- MenuBar._channel_menu_item_tag(GeneratorName.PULSE2): False,
- MenuBar._channel_menu_item_tag(GeneratorName.TRIANGLE): True,
- MenuBar._channel_menu_item_tag(GeneratorName.NOISE): True,
+ MenuBar._channel_menu_item_tag(ChannelName.PULSE1): True,
+ MenuBar._channel_menu_item_tag(ChannelName.PULSE2): False,
+ MenuBar._channel_menu_item_tag(ChannelName.TRIANGLE): True,
+ MenuBar._channel_menu_item_tag(ChannelName.NOISE): True,
}
def test_the_restore_follows_the_mute_set(
@@ -402,7 +425,7 @@ def test_the_restore_follows_the_mute_set(
menu_bar: MenuBar,
framework: _DearPyGuiRecorder,
) -> None:
- menu_bar._update_channels(_state(frozenset({GeneratorName.PULSE2})))
+ menu_bar._update_channels(_state(frozenset({ChannelName.PULSE2})))
assert framework.enabled == {TAG_GLOBAL_MENU_ITEM_PLAYBACK_UNMUTE_ALL_CHANNELS: True}
@@ -421,8 +444,11 @@ def _edit_bar(build_edit_actions: Callable[[], bool]) -> MenuBar:
instance = MenuBar.__new__(MenuBar)
instance._language_manager = LanguageManager(LANG_EN)
instance._build_edit_actions = build_edit_actions
- instance._edit_actions_frame = None
- instance._edit_action_items = ()
+ instance._edit_section = MenuSection(
+ menu_tag=TAG_GLOBAL_MENU_GROUP_EDIT,
+ marker_tag=TAG_GLOBAL_MENU_GROUP_EDIT_MARKER,
+ build=instance._add_edit_action_items,
+ )
return instance
@@ -497,11 +523,11 @@ class TestEditActionsSection:
"""The Edit menu carries the actions of the grid holding the cursor, and names them itself
while no grid holds one."""
- def test_the_clipboard_actions_are_named_greyed_out_with_no_grid_focused(
+ def test_the_clipboard_actions_are_named_grayed_out_with_no_grid_focused(
self,
framework: _DearPyGuiRecorder,
) -> None:
- _edit_bar(lambda: False)._refresh_edit_actions()
+ _edit_bar(lambda: False)._edit_section.refresh()
assert [item["label"] for item in framework.items] == ["Copy", "Cut", "Paste", "Delete"]
assert [item["enabled"] for item in framework.items] == [False] * 4
@@ -516,7 +542,7 @@ def build() -> bool:
requests.append(True)
return True
- _edit_bar(build)._refresh_edit_actions()
+ _edit_bar(build)._edit_section.refresh()
assert requests == [True]
assert framework.items == []
@@ -525,7 +551,7 @@ def test_the_actions_are_stated_into_the_menu_itself(
self,
framework: _DearPyGuiRecorder,
) -> None:
- _edit_bar(lambda: False)._refresh_edit_actions()
+ _edit_bar(lambda: False)._edit_section.refresh()
assert framework.containers == [TAG_GLOBAL_MENU_GROUP_EDIT]
@@ -535,8 +561,8 @@ def test_a_build_takes_away_only_what_the_one_before_it_stated(
) -> None:
menu_bar = _edit_bar(lambda: False)
- menu_bar._refresh_edit_actions()
- menu_bar._refresh_edit_actions()
+ menu_bar._edit_section.refresh()
+ menu_bar._edit_section.refresh()
assert framework.deleted == [0, 1, 2, 3]
@@ -565,36 +591,84 @@ def test_the_marker_stands_before_every_item(
]
-class TestEditActionsRefresh:
- """DearPyGui reports the section drawn once a frame while the menu stands open, so a gap in
- those reports is what marks a fresh opening."""
+class TestVoiceMenu:
+ """One group names every way a voice comes in, and carries what the chosen one offers."""
- def test_the_actions_are_stated_once_while_the_menu_stays_open(
+ def test_the_ways_a_voice_comes_in_are_named_together(
self,
- framework: _DearPyGuiRecorder,
- monkeypatch: pytest.MonkeyPatch,
+ menu_bar: MenuBar,
+ shortcuts: _ShortcutManagerRecorder,
+ ) -> None:
+ menu_bar._create_voice_menu(_state(frozenset()))
+
+ assert shortcuts.labels == [
+ "New instrument",
+ "Add sample from file...",
+ "Import instrument...",
+ "Add to Sequencer",
+ ]
+
+ def test_each_way_carries_its_own_action(
+ self,
+ menu_bar: MenuBar,
+ shortcuts: _ShortcutManagerRecorder,
+ ) -> None:
+ menu_bar._create_voice_menu(_state(frozenset()))
+
+ assert [item["shortcut_id"] for item in shortcuts.items] == [
+ ShortcutId.NEW_INSTRUMENT,
+ ShortcutId.ADD_SAMPLE_FROM_FILE,
+ ShortcutId.IMPORT_INSTRUMENT,
+ ShortcutId.ADD_RECONSTRUCTION_TO_SEQUENCER,
+ ]
+
+ def test_the_pool_waits_for_a_project_to_hold_a_voice(
+ self,
+ menu_bar: MenuBar,
+ shortcuts: _ShortcutManagerRecorder,
) -> None:
- frames = iter([10, 11, 12, 13])
- monkeypatch.setattr(menu_module.dpg, "get_frame_count", lambda: next(frames))
- requests: List[int] = []
- menu_bar = _edit_bar(lambda: bool(requests.append(1)))
+ menu_bar._create_voice_menu(_state(frozenset()).model_copy(update={"project_open": False}))
- for _ in range(4):
- menu_bar._on_edit_actions_drawn(0, 0)
+ assert shortcuts.item("New instrument")["enabled"] is False
+ assert shortcuts.item("Add sample from file...")["enabled"] is False
+ assert shortcuts.item("Import instrument...")["enabled"] is False
+
+ def test_bringing_the_open_reconstruction_in_waits_for_one_to_be_open(
+ self,
+ menu_bar: MenuBar,
+ shortcuts: _ShortcutManagerRecorder,
+ ) -> None:
+ menu_bar._create_voice_menu(_state(frozenset()))
- assert len(requests) == 1
+ assert shortcuts.item("Add to Sequencer")["enabled"] is False
- def test_the_actions_are_stated_afresh_each_time_the_menu_is_opened(
+ def test_the_marker_stands_before_every_item(
self,
+ menu_bar: MenuBar,
framework: _DearPyGuiRecorder,
- monkeypatch: pytest.MonkeyPatch,
) -> None:
- frames = iter([10, 11, 40, 41])
- monkeypatch.setattr(menu_module.dpg, "get_frame_count", lambda: next(frames))
- requests: List[int] = []
- menu_bar = _edit_bar(lambda: bool(requests.append(1)))
+ menu_bar._create_voice_menu(_state(frozenset()))
+
+ assert framework.built[0] == f"group:{TAG_GLOBAL_MENU_GROUP_VOICE_MARKER}"
+
+ def test_the_chosen_voice_is_asked_for_as_the_group_is_built(
+ self,
+ shortcuts: _ShortcutManagerRecorder,
+ framework: _DearPyGuiRecorder,
+ switched: List[ChannelName],
+ ) -> None:
+ """The section is stated once the group stands, so the menu opens with its full height."""
+ asked: List[int] = []
+ instance = MenuBar.__new__(MenuBar)
+ instance._shortcut_manager = shortcuts
+ instance._language_manager = LanguageManager(LANG_EN)
+ instance._voice_section = MenuSection(
+ menu_tag=TAG_GLOBAL_MENU_GROUP_VOICE,
+ marker_tag=TAG_GLOBAL_MENU_GROUP_VOICE_MARKER,
+ build=lambda: asked.append(1),
+ )
- for _ in range(4):
- menu_bar._on_edit_actions_drawn(0, 0)
+ instance._create_voice_menu(_state(frozenset()))
- assert len(requests) == 2
+ assert asked == [1]
+ assert framework.containers == [TAG_GLOBAL_MENU_GROUP_VOICE]
diff --git a/tests/unit/sampletones_application/ui/themes/test_inline.py b/tests/unit/sampletones_application/ui/themes/test_inline.py
index 843cf25e6..e68a96093 100644
--- a/tests/unit/sampletones_application/ui/themes/test_inline.py
+++ b/tests/unit/sampletones_application/ui/themes/test_inline.py
@@ -34,7 +34,7 @@ def _item_types(theme: int) -> Set[int]:
def _colors(theme: int, *, enabled_state: bool) -> Dict[int, ColorRGBA]:
- """The colours one component carries, keyed by the DearPyGui colour they target."""
+ """The colors one component carries, keyed by the DearPyGui color they target."""
component = _components(theme)[enabled_state]
return {
dpg.get_item_configuration(entry)["target"]: tuple(int(value) for value in dpg.get_value(entry))
@@ -52,14 +52,14 @@ def context() -> Generator[None, None, None]:
class TestSelectableTextTheme:
- def test_the_text_colour_is_the_theme_s_whole_claim(self, context: None) -> None:
+ def test_the_text_color_is_the_theme_s_whole_claim(self, context: None) -> None:
"""A cell keeps the hover and selection shades of the table it sits in."""
theme = create_selectable_text_theme(TEXT_COLOR)
assert _colors(theme, enabled_state=True) == {dpg.mvThemeCol_Text: TEXT_RGBA}
@pytest.mark.parametrize("enabled_state", ENABLED_STATES, ids=["enabled", "disabled"])
- def test_both_enabled_states_carry_the_colour(self, context: None, enabled_state: bool) -> None:
+ def test_both_enabled_states_carry_the_color(self, context: None, enabled_state: bool) -> None:
theme = create_selectable_text_theme(TEXT_COLOR)
assert _colors(theme, enabled_state=enabled_state)[dpg.mvThemeCol_Text] == TEXT_RGBA
diff --git a/tests/unit/sampletones_application/ui/themes/test_loader.py b/tests/unit/sampletones_application/ui/themes/test_loader.py
index 2dbdcf407..205a3c464 100644
--- a/tests/unit/sampletones_application/ui/themes/test_loader.py
+++ b/tests/unit/sampletones_application/ui/themes/test_loader.py
@@ -5,9 +5,14 @@
import pytest
from sampletones_application.paths import PALETTES_DIRECTORY, THEME_DIRECTORY
-from sampletones_application.tags.general import TAG_GLOBAL_THEME_DEFAULT
+from sampletones_application.tags.general import (
+ TAG_GLOBAL_THEME_DANGER_BUTTON,
+ TAG_GLOBAL_THEME_DEFAULT,
+)
+from sampletones_application.ui.themes.dpg_constants import EVERY_ITEM_TYPE
from sampletones_application.ui.themes.loader import ThemeLoader
from sampletones_application.ui.themes.spec import ThemeSpec
+from sampletones_application.ui.themes.style import ThemeParameter
from sampletones_application.ui.themes.theme import Theme
from sampletones_application.utils.palette.catalog import PaletteCatalog
from sampletones_application.utils.palette.palette import Palette
@@ -64,7 +69,7 @@ def test_an_explicit_parent_is_respected(self) -> None:
class TestLoadedInheritance:
"""The real theme set: every theme resolves to the base plus its own overrides,
- so a bound item theme keeps the base's colours instead of dropping to DearPyGui
+ so a bound item theme keeps the base's colors instead of dropping to DearPyGui
defaults for anything it omits.
"""
@@ -108,6 +113,28 @@ def test_a_theme_keeps_its_own_override_on_top_of_the_base(self, themes: Dict[st
finally:
dpg.destroy_context()
+ def test_the_danger_button_states_its_own_disabled_look(self, themes: Dict[str, Theme]) -> None:
+ """A button held back reads as held back.
+
+ Every theme is completed for both states, so one stating only the tone it wears while it can
+ be pressed would wear that same tone once it is held back and read as a button that simply
+ does nothing. The danger button states the grayed look itself, which is what the stems list
+ relies on to show that its last row stays.
+ """
+ dpg.create_context()
+ try:
+ danger = themes[TAG_GLOBAL_THEME_DANGER_BUTTON]
+ danger.create()
+
+ pressable = danger.get_color(dpg.mvButton, dpg.mvThemeCol_Button)
+ held_back = danger.get_color(dpg.mvButton, dpg.mvThemeCol_Button, enabled_state=False)
+
+ assert pressable is not None
+ assert held_back is not None
+ assert held_back != pressable
+ finally:
+ dpg.destroy_context()
+
def test_the_tracker_theme_stands_the_pattern_on_one_even_ground(self, themes: Dict[str, Theme]) -> None:
"""The tracker gives both stripes the same shade, leaving the row background free to
carry the beat and bar grouping that tells the pattern's rows apart.
@@ -126,6 +153,47 @@ def test_the_tracker_theme_stands_the_pattern_on_one_even_ground(self, themes: D
dpg.destroy_context()
+class TestComponentOrder:
+ """A theme lays its ground before it paints on it.
+
+ DearPyGui fills an item from a theme's components in the order they were created, so the last
+ one covering a color is the one the item wears. A component naming every item type is the
+ ground a theme stands on; one naming a single type states what that type is meant to look like,
+ and it only reaches the item if it comes after the ground.
+ """
+
+ @pytest.fixture
+ def themes(self) -> Dict[str, Theme]:
+ source = PaletteSource(PaletteCatalog.load(PALETTES_DIRECTORY).default)
+ return {theme.tag: theme for theme in ThemeLoader(THEME_DIRECTORY, source).load_all()}
+
+ def test_a_component_naming_every_type_comes_before_the_ones_naming_a_type(
+ self,
+ themes: Dict[str, Theme],
+ ) -> None:
+ for theme in themes.values():
+ grounds = [
+ index for index, component in enumerate(theme.components) if component.item_type == EVERY_ITEM_TYPE
+ ]
+ specifics = [
+ index for index, component in enumerate(theme.components) if component.item_type != EVERY_ITEM_TYPE
+ ]
+ assert not grounds or not specifics or max(grounds) < min(specifics), theme.tag
+
+ def test_the_danger_button_paints_its_disabled_look_over_the_ground(
+ self,
+ themes: Dict[str, Theme],
+ ) -> None:
+ """The mirror completes the ground for both states, so the button has to paint after it."""
+ danger = themes[TAG_GLOBAL_THEME_DANGER_BUTTON]
+ held_back = ThemeParameter(item_type=dpg.mvButton, enabled_state=False)
+ ground = ThemeParameter(item_type=EVERY_ITEM_TYPE, enabled_state=False)
+
+ components = list(danger.components)
+
+ assert components.index(ground) < components.index(held_back)
+
+
class TestDisabledStateMirroring:
"""Disabled-state completeness: DearPyGui resolves each item against the theme
component matching the item's enabled state and re-applies its built-in palette
diff --git a/tests/unit/sampletones_application/ui/themes/test_theme.py b/tests/unit/sampletones_application/ui/themes/test_theme.py
index 396ebe551..5c35b2741 100644
--- a/tests/unit/sampletones_application/ui/themes/test_theme.py
+++ b/tests/unit/sampletones_application/ui/themes/test_theme.py
@@ -45,14 +45,14 @@
class _Styled(NamedTuple):
- """A created theme and the source whose palette its colours read."""
+ """A created theme and the source whose palette its colors read."""
theme: Theme
source: PaletteSource
def _live_colors(theme: Theme) -> Dict[int, ColorRGBA]:
- """The colours DearPyGui holds for the theme's enabled ``All`` component, keyed by target."""
+ """The colors DearPyGui holds for the theme's enabled ``All`` component, keyed by target."""
component = dpg.get_item_children(theme.tag, slot=1)[0]
return {
dpg.get_item_configuration(entry)["target"]: tuple(int(channel) for channel in dpg.get_value(entry))
@@ -91,14 +91,14 @@ def test_the_theme_is_built_once_for_its_tag(self, styled: _Styled) -> None:
assert dpg.get_item_children(styled.theme.tag, slot=1) == components
- def test_a_referenced_colour_reaches_dearpygui_resolved(self, styled: _Styled) -> None:
+ def test_a_referenced_color_reaches_dearpygui_resolved(self, styled: _Styled) -> None:
assert _live_colors(styled.theme)[dpg.mvThemeCol_Text] == STUDIO_TEXT
class TestRestyle:
- """A palette swap reaches themed widgets by rewriting the colour items already created."""
+ """A palette swap reaches themed widgets by rewriting the color items already created."""
- def test_a_referenced_colour_takes_the_newly_activated_palette(
+ def test_a_referenced_color_takes_the_newly_activated_palette(
self,
styled: _Styled,
light: Palette,
@@ -108,13 +108,13 @@ def test_a_referenced_colour_takes_the_newly_activated_palette(
assert _live_colors(styled.theme)[dpg.mvThemeCol_Text] == LIGHT_TEXT
- def test_a_literal_colour_stays_as_written(self, styled: _Styled, light: Palette) -> None:
+ def test_a_literal_color_stays_as_written(self, styled: _Styled, light: Palette) -> None:
styled.source.activate(light)
PaletteBindings.apply()
assert _live_colors(styled.theme)[dpg.mvThemeCol_WindowBg] == LITERAL_BACKGROUND
- def test_the_reported_colour_follows_the_palette_before_any_restyle(
+ def test_the_reported_color_follows_the_palette_before_any_restyle(
self,
styled: _Styled,
light: Palette,
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 a16487f21..24eec0e81 100644
--- a/tests/unit/sampletones_application/utils/file_dialogs/test_selection.py
+++ b/tests/unit/sampletones_application/utils/file_dialogs/test_selection.py
@@ -1,5 +1,6 @@
import os
from contextlib import AbstractContextManager
+from pathlib import Path
from typing import Callable, Optional
from unittest.mock import MagicMock, patch
@@ -24,11 +25,11 @@
PORTAL_MODULE = "sampletones_application.utils.file_dialogs.backends.portal.backend"
-def _which(*, kdialog: bool, zenity: bool) -> Callable[[str], Optional[str]]:
+def _located(*, kdialog: bool, zenity: bool) -> Callable[[str], Optional[Path]]:
available = {"kdialog": kdialog, "zenity": zenity}
- def resolver(tool: str) -> Optional[str]:
- return f"/usr/bin/{tool}" if available.get(tool, False) else None
+ def resolver(tool: str) -> Optional[Path]:
+ return Path(f"/usr/bin/{tool}") if available.get(tool, False) else None
return resolver
@@ -38,9 +39,9 @@ def _portal(backend: Optional[PortalBackend]) -> AbstractContextManager[MagicMoc
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
+def _available(installed: bool) -> Callable[[str], bool]:
+ def resolver(module: str) -> bool:
+ return installed
return resolver
@@ -59,7 +60,7 @@ def test_the_portal_leads_where_it_answers(self) -> None:
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)),
+ patch(f"{MODULE}.locate_program", side_effect=_located(kdialog=True, zenity=True)),
_portal(portal),
patch.dict(os.environ, {"XDG_CURRENT_DESKTOP": "KDE"}, clear=False),
):
@@ -68,7 +69,7 @@ def test_the_portal_leads_where_it_answers(self) -> None:
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)),
+ patch(f"{MODULE}.locate_program", side_effect=_located(kdialog=True, zenity=True)),
_portal(None),
patch.dict(os.environ, {"XDG_CURRENT_DESKTOP": "KDE"}, clear=False),
):
@@ -77,7 +78,7 @@ def test_kde_prefers_kdialog(self) -> None:
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)),
+ patch(f"{MODULE}.locate_program", side_effect=_located(kdialog=True, zenity=True)),
_portal(None),
patch.dict(os.environ, {"XDG_CURRENT_DESKTOP": "GNOME"}, clear=False),
):
@@ -86,7 +87,7 @@ def test_gnome_prefers_zenity(self) -> None:
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)),
+ patch(f"{MODULE}.locate_program", side_effect=_located(kdialog=False, zenity=True)),
_portal(None),
patch.dict(os.environ, {"XDG_CURRENT_DESKTOP": "KDE"}, clear=False),
):
@@ -96,8 +97,8 @@ 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),
+ f"{MODULE}.locate_program",
+ side_effect=_located(kdialog=False, zenity=False),
),
_portal(None),
patch.dict(os.environ, {"XDG_CURRENT_DESKTOP": "GNOME"}, clear=False),
@@ -107,11 +108,8 @@ def test_no_linux_tools_uses_tkinter(self) -> None:
def test_linux_tools_win_over_missing_tkinter(self) -> None:
with (
patch(f"{MODULE}.System.current", return_value=System.LINUX),
- patch(f"{MODULE}.shutil.which", side_effect=_which(kdialog=True, zenity=True)),
- patch(
- f"{MODULE}.importlib.util.find_spec",
- side_effect=_find_spec(available=False),
- ),
+ patch(f"{MODULE}.locate_program", side_effect=_located(kdialog=True, zenity=True)),
+ patch(f"{MODULE}.module_available", side_effect=_available(installed=False)),
patch.dict(os.environ, {"XDG_CURRENT_DESKTOP": "KDE"}, clear=False),
):
assert isinstance(select_file_dialog_backend(), KDialogBackend)
@@ -120,13 +118,10 @@ def test_no_linux_tools_without_tkinter_raises(self) -> None:
with (
patch(f"{MODULE}.System.current", return_value=System.LINUX),
patch(
- f"{MODULE}.shutil.which",
- side_effect=_which(kdialog=False, zenity=False),
- ),
- patch(
- f"{MODULE}.importlib.util.find_spec",
- side_effect=_find_spec(available=False),
+ f"{MODULE}.locate_program",
+ side_effect=_located(kdialog=False, zenity=False),
),
+ patch(f"{MODULE}.module_available", side_effect=_available(installed=False)),
patch.dict(os.environ, {"XDG_CURRENT_DESKTOP": "GNOME"}, clear=False),
pytest.raises(FileDialogUnavailableError),
):
@@ -135,10 +130,7 @@ def test_no_linux_tools_without_tkinter_raises(self) -> None:
def test_windows_without_tkinter_raises(self) -> None:
with (
patch(f"{MODULE}.System.current", return_value=System.WINDOWS),
- patch(
- f"{MODULE}.importlib.util.find_spec",
- side_effect=_find_spec(available=False),
- ),
+ patch(f"{MODULE}.module_available", side_effect=_available(installed=False)),
pytest.raises(FileDialogUnavailableError),
):
select_file_dialog_backend()
diff --git a/tests/unit/sampletones_application/utils/gui/dialogs/windows/__init__.py b/tests/unit/sampletones_application/utils/gui/dialogs/windows/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/tests/unit/sampletones_application/utils/gui/dialogs/windows/conftest.py b/tests/unit/sampletones_application/utils/gui/dialogs/windows/conftest.py
new file mode 100644
index 000000000..514f516d7
--- /dev/null
+++ b/tests/unit/sampletones_application/utils/gui/dialogs/windows/conftest.py
@@ -0,0 +1,38 @@
+from typing import Iterator
+
+import dearpygui.dearpygui as dpg
+import pytest
+
+from sampletones_application.layout.config import LayoutConfig
+from sampletones_application.layout.loader import load_layout_config
+from sampletones_application.paths import (
+ BEHAVIOR_DIRECTORY,
+ LAYOUT_DIRECTORY,
+ PALETTES_DIRECTORY,
+ THEME_DIRECTORY,
+)
+from sampletones_application.ui.elements.fonts.registry import FontRegistry
+from sampletones_application.ui.themes.registry import ThemeRegistry
+from sampletones_application.ui.themes.setup import setup_themes
+from sampletones_application.utils.palette.catalog import PaletteCatalog
+from sampletones_application.utils.palette.source import PaletteSource
+
+
+@pytest.fixture
+def layout_config() -> LayoutConfig:
+ source = PaletteSource(PaletteCatalog.load(PALETTES_DIRECTORY).default)
+ return load_layout_config(LAYOUT_DIRECTORY, BEHAVIOR_DIRECTORY, source)
+
+
+@pytest.fixture
+def dpg_context(layout_config: LayoutConfig) -> Iterator[None]:
+ """Stands up the context, fonts and themes a dialog resolves on construction, as startup does."""
+ dpg.create_context()
+ FontRegistry.setup(layout_config.fonts)
+ FontRegistry.register_fonts(layout_config.fonts.scale)
+ setup_themes(THEME_DIRECTORY, PaletteSource(PaletteCatalog.load(PALETTES_DIRECTORY).default))
+ try:
+ yield
+ finally:
+ ThemeRegistry.clear()
+ dpg.destroy_context()
diff --git a/tests/unit/sampletones_application/utils/gui/dialogs/windows/test_confirmation.py b/tests/unit/sampletones_application/utils/gui/dialogs/windows/test_confirmation.py
new file mode 100644
index 000000000..4ad5efcc5
--- /dev/null
+++ b/tests/unit/sampletones_application/utils/gui/dialogs/windows/test_confirmation.py
@@ -0,0 +1,105 @@
+from pathlib import Path
+from typing import Final, List
+from unittest.mock import MagicMock
+
+import dearpygui.dearpygui as dpg
+import pytest
+
+from sampletones_application.layout.config import LayoutConfig
+from sampletones_application.tags.compose import compose_tag
+from sampletones_application.tags.general import (
+ SUF_BUTTON,
+ SUF_BUTTON_CANCEL,
+ SUF_BUTTON_OK,
+ SUF_CHECKBOX,
+ TAG_GLOBAL_DIALOG_PATH_MESSAGE,
+)
+from sampletones_application.utils.gui.dialogs import get_dialog_tag
+from sampletones_application.utils.gui.dialogs.windows.confirmation import (
+ GUIConfirmationWindow,
+)
+from sampletones_application.utils.gui.keyboard import KeyRouter
+from tests.suite.shortcuts import shipped_source
+
+WINDOW_TAG: Final[str] = get_dialog_tag(TAG_GLOBAL_DIALOG_PATH_MESSAGE)
+CONFIRMED: Final[str] = "confirmed"
+CANCELED: Final[str] = "canceled"
+OPTED_OUT: Final[str] = "opted_out"
+
+
+@pytest.fixture(name="window")
+def window_fixture(dpg_context: None, layout_config: LayoutConfig) -> GUIConfirmationWindow:
+ return GUIConfirmationWindow(
+ tag=WINDOW_TAG,
+ width=layout_config.general.dialogs.default.width,
+ height=layout_config.general.dialogs.confirmation.height,
+ wrap=layout_config.general.dialogs.default.width - 10,
+ path_color=layout_config.general.colors.paths.default,
+ path_hover_color=layout_config.general.colors.paths.hover,
+ path_message="path",
+ status_bar=MagicMock(),
+ key_router=KeyRouter(),
+ shortcut_source=shipped_source(),
+ )
+
+
+def render(
+ window: GUIConfirmationWindow,
+ *,
+ path: Path | None = None,
+ opt_out_label: str | None = None,
+ answers: List[str] | None = None,
+) -> None:
+ """Builds the prompt for the given question, the way ``show`` does without a live frame."""
+ window.prepare(
+ "Save it?",
+ "Title",
+ lambda: answers.append(CONFIRMED) if answers is not None else None,
+ ok_label="Yes",
+ cancel_label="No",
+ path=path,
+ opt_out_label=opt_out_label,
+ on_opt_out=lambda: answers.append(OPTED_OUT) if answers is not None else None,
+ on_cancel=lambda: answers.append(CANCELED) if answers is not None else None,
+ )
+ window.create_window()
+
+
+def press(tag: str) -> None:
+ dpg.get_item_callback(compose_tag(tag, SUF_BUTTON))()
+
+
+class TestConfirmationWindow:
+ def test_ok_runs_the_confirmation_and_closes(self, window: GUIConfirmationWindow) -> None:
+ answers: List[str] = []
+ render(window, answers=answers)
+
+ press(compose_tag(WINDOW_TAG, SUF_BUTTON_OK))
+
+ assert answers == [CONFIRMED]
+ assert not dpg.does_item_exist(WINDOW_TAG)
+
+ def test_cancel_runs_the_negative_answer_and_closes(self, window: GUIConfirmationWindow) -> None:
+ answers: List[str] = []
+ render(window, answers=answers)
+
+ press(compose_tag(WINDOW_TAG, SUF_BUTTON_CANCEL))
+
+ assert answers == [CANCELED]
+ assert not dpg.does_item_exist(WINDOW_TAG)
+
+ def test_a_ticked_opt_out_rides_the_confirmation(self, window: GUIConfirmationWindow) -> None:
+ answers: List[str] = []
+ render(window, opt_out_label="Do not ask again", answers=answers)
+ dpg.set_value(compose_tag(WINDOW_TAG, SUF_CHECKBOX), True)
+
+ press(compose_tag(WINDOW_TAG, SUF_BUTTON_OK))
+
+ assert answers == [OPTED_OUT, CONFIRMED]
+
+ def test_the_path_is_shown_when_given(self, window: GUIConfirmationWindow, tmp_path: Path) -> None:
+ path = tmp_path / "song.stn"
+
+ render(window, path=path)
+
+ assert dpg.does_item_exist(compose_tag(WINDOW_TAG, "path"))
diff --git a/tests/unit/sampletones_application/utils/gui/dialogs/windows/test_error.py b/tests/unit/sampletones_application/utils/gui/dialogs/windows/test_error.py
new file mode 100644
index 000000000..d65bb9bfd
--- /dev/null
+++ b/tests/unit/sampletones_application/utils/gui/dialogs/windows/test_error.py
@@ -0,0 +1,73 @@
+from typing import Final
+
+import dearpygui.dearpygui as dpg
+import pytest
+
+from sampletones_application.categories.manager import LanguageManager
+from sampletones_application.layout.config import LayoutConfig
+from sampletones_application.paths import LANG_EN
+from sampletones_application.tags.compose import compose_tag
+from sampletones_application.tags.general import (
+ SUF_BUTTON,
+ SUF_BUTTON_OK,
+ SUF_BUTTON_SHOW_TRACEBACK,
+ SUF_GROUP,
+ TAG_GLOBAL_DIALOG_ERROR,
+)
+from sampletones_application.utils.gui.dialogs import get_dialog_tag
+from sampletones_application.utils.gui.dialogs.windows.error import (
+ GUIErrorDialogWindow,
+)
+from sampletones_application.utils.gui.keyboard import KeyRouter
+from tests.suite.shortcuts import shipped_source
+
+LANGUAGE_MANAGER: Final[LanguageManager] = LanguageManager(LANG_EN)
+WINDOW_TAG: Final[str] = get_dialog_tag(TAG_GLOBAL_DIALOG_ERROR)
+
+
+@pytest.fixture(name="window")
+def window_fixture(dpg_context: None, layout_config: LayoutConfig) -> GUIErrorDialogWindow:
+ return GUIErrorDialogWindow(
+ tag=WINDOW_TAG,
+ width=layout_config.general.dialogs.error.width,
+ height=layout_config.general.dialogs.error.height,
+ wrap=layout_config.general.dialogs.error.width - 10,
+ language_manager=LANGUAGE_MANAGER,
+ error_color=layout_config.general.colors.text.error,
+ key_router=KeyRouter(),
+ shortcut_source=shipped_source(),
+ )
+
+
+def render(window: GUIErrorDialogWindow, message: str = "context") -> None:
+ """Builds the widget tree for the given failure, the way ``show`` does without a live frame."""
+ window.prepare(RuntimeError("boom"), message)
+ window.create_window()
+
+
+def press(tag: str) -> None:
+ dpg.get_item_callback(compose_tag(tag, SUF_BUTTON))()
+
+
+class TestErrorWindow:
+ def test_reports_the_exception_name_and_text(self, window: GUIErrorDialogWindow) -> None:
+ render(window)
+
+ texts = [dpg.get_value(item) for item in dpg.get_item_children(compose_tag(WINDOW_TAG, SUF_GROUP), 1)]
+ assert any(text.startswith("RuntimeError") for text in texts)
+ assert "boom" in texts
+
+ def test_the_traceback_toggle_flips_its_label(self, window: GUIErrorDialogWindow) -> None:
+ render(window)
+ show_inner_tag = compose_tag(WINDOW_TAG, SUF_BUTTON_SHOW_TRACEBACK, SUF_BUTTON)
+
+ press(compose_tag(WINDOW_TAG, SUF_BUTTON_SHOW_TRACEBACK))
+
+ assert dpg.get_item_label(show_inner_tag) == LANGUAGE_MANAGER["global.traceback.label.hide"]
+
+ def test_ok_dismisses_the_prompt(self, window: GUIErrorDialogWindow) -> None:
+ render(window)
+
+ press(compose_tag(WINDOW_TAG, SUF_BUTTON_OK))
+
+ assert not dpg.does_item_exist(WINDOW_TAG)
diff --git a/tests/unit/sampletones_application/utils/gui/dialogs/windows/test_save_confirmation.py b/tests/unit/sampletones_application/utils/gui/dialogs/windows/test_save_confirmation.py
new file mode 100644
index 000000000..d5d4b373f
--- /dev/null
+++ b/tests/unit/sampletones_application/utils/gui/dialogs/windows/test_save_confirmation.py
@@ -0,0 +1,96 @@
+from typing import Final, List
+
+import dearpygui.dearpygui as dpg
+import pytest
+
+from sampletones_application.layout.config import LayoutConfig
+from sampletones_application.tags.compose import compose_tag
+from sampletones_application.tags.general import (
+ SUF_BUTTON,
+ SUF_BUTTON_CANCEL,
+ SUF_BUTTON_OK,
+ SUF_BUTTON_SAVE,
+ TAG_GLOBAL_DIALOG_FILE_NOT_FOUND,
+)
+from sampletones_application.utils.gui.dialogs import get_dialog_tag
+from sampletones_application.utils.gui.dialogs.windows.save_confirmation import (
+ GUISaveConfirmationWindow,
+)
+from sampletones_application.utils.gui.keyboard import KeyRouter
+from tests.suite.shortcuts import shipped_source
+
+WINDOW_TAG: Final[str] = get_dialog_tag(TAG_GLOBAL_DIALOG_FILE_NOT_FOUND)
+CONFIRMED: Final[str] = "confirmed"
+
+
+@pytest.fixture(name="window")
+def window_fixture(dpg_context: None, layout_config: LayoutConfig) -> GUISaveConfirmationWindow:
+ return GUISaveConfirmationWindow(
+ tag=WINDOW_TAG,
+ width=layout_config.general.dialogs.default.width,
+ height=layout_config.general.dialogs.confirmation.height,
+ wrap=layout_config.general.dialogs.default.width - 10,
+ save_label="Save",
+ cancel_label="Cancel",
+ key_router=KeyRouter(),
+ shortcut_source=shipped_source(),
+ )
+
+
+def render(
+ window: GUISaveConfirmationWindow,
+ *,
+ save_succeeds: bool,
+ answers: List[str],
+) -> None:
+ """Builds the prompt for the given save, the way ``show`` does without a live frame."""
+ window.prepare(
+ "Save first?",
+ "Title",
+ lambda: save_succeeds,
+ lambda: answers.append(CONFIRMED),
+ ok_label="Proceed",
+ )
+ window.create_window()
+
+
+def press(tag: str) -> None:
+ dpg.get_item_callback(compose_tag(tag, SUF_BUTTON))()
+
+
+class TestSaveConfirmationWindow:
+ def test_a_canceled_save_keeps_the_prompt_open(self, window: GUISaveConfirmationWindow) -> None:
+ answers: List[str] = []
+ render(window, save_succeeds=False, answers=answers)
+
+ press(compose_tag(WINDOW_TAG, SUF_BUTTON_SAVE))
+
+ assert answers == []
+ assert dpg.does_item_exist(WINDOW_TAG)
+
+ def test_a_completed_save_proceeds_and_closes(self, window: GUISaveConfirmationWindow) -> None:
+ answers: List[str] = []
+ render(window, save_succeeds=True, answers=answers)
+
+ press(compose_tag(WINDOW_TAG, SUF_BUTTON_SAVE))
+
+ assert answers == [CONFIRMED]
+ assert not dpg.does_item_exist(WINDOW_TAG)
+
+ def test_the_middle_button_proceeds_without_saving(self, window: GUISaveConfirmationWindow) -> None:
+ answers: List[str] = []
+ render(window, save_succeeds=False, answers=answers)
+
+ press(compose_tag(WINDOW_TAG, SUF_BUTTON_OK))
+
+ assert answers == [CONFIRMED]
+ assert not dpg.does_item_exist(WINDOW_TAG)
+
+ def test_cancel_dismisses_the_prompt(self, window: GUISaveConfirmationWindow) -> None:
+ answers: List[str] = []
+ render(window, save_succeeds=False, answers=answers)
+
+ press(compose_tag(WINDOW_TAG, SUF_BUTTON_CANCEL))
+
+ assert answers == []
+ assert not dpg.does_item_exist(WINDOW_TAG)
diff --git a/tests/unit/sampletones_application/utils/gui/keyboard/test_capture.py b/tests/unit/sampletones_application/utils/gui/keyboard/test_capture.py
index ca2ae9fca..d0b96015e 100644
--- a/tests/unit/sampletones_application/utils/gui/keyboard/test_capture.py
+++ b/tests/unit/sampletones_application/utils/gui/keyboard/test_capture.py
@@ -37,10 +37,10 @@ class Harness:
def __init__(self) -> None:
self.router = KeyRouter()
self.captured: List[KeyCombination] = []
- self.cancelled = 0
+ self.canceled = 0
self.capture = KeyCapture(key_router=self.router, cancel=CANCEL)
self.capture.on_captured = self.captured.append
- self.capture.on_cancelled = self._on_cancelled
+ self.capture.on_canceled = self._on_canceled
def press(self, key: int, modifiers: ModifierSet = NO_MODIFIERS) -> None:
self.router.route(KeyEvent(key=key, modifiers=modifiers))
@@ -49,8 +49,8 @@ def press_all(self, events: Tuple[KeyEvent, ...]) -> None:
for event in events:
self.press(event.key, event.modifiers)
- def _on_cancelled(self) -> None:
- self.cancelled += 1
+ def _on_canceled(self) -> None:
+ self.canceled += 1
@pytest.fixture(name="harness")
@@ -234,16 +234,16 @@ def test_a_key_pressed_after_one_the_table_names_none_of_is_read(self, harness:
assert harness.captured == [KeyCombination(dpg.mvKey_D, CTRL)]
-class TestCancelledCapture:
+class TestCanceledCapture:
def test_the_cancel_combination_ends_the_capture_without_assigning(self, harness: Harness) -> None:
harness.press(dpg.mvKey_Escape)
assert harness.captured == []
- assert harness.cancelled == 1
+ assert harness.canceled == 1
assert not harness.capture.is_listening
def test_the_cancel_key_under_a_modifier_is_a_combination_like_any_other(self, harness: Harness) -> None:
harness.press(dpg.mvKey_Escape, CTRL)
assert harness.captured == [KeyCombination(dpg.mvKey_Escape, CTRL)]
- assert harness.cancelled == 0
+ assert harness.canceled == 0
diff --git a/tests/unit/sampletones_application/utils/gui/keyboard/test_keys.py b/tests/unit/sampletones_application/utils/gui/keyboard/test_keys.py
index c1ebf3e64..71243a974 100644
--- a/tests/unit/sampletones_application/utils/gui/keyboard/test_keys.py
+++ b/tests/unit/sampletones_application/utils/gui/keyboard/test_keys.py
@@ -241,7 +241,7 @@ class TestCase(BaseRegularTestCase):
test_cases,
ids=lambda test_case: test_case.label,
)
- def test_a_written_key_seats_between_the_keys_it_neighbours(self, test_case: TestCase) -> None:
+ def test_a_written_key_seats_between_the_keys_it_neighbors(self, test_case: TestCase) -> None:
assert test_case.key == test_case.preceding + 1
assert test_case.following == test_case.key + 1
diff --git a/tests/unit/sampletones_application/utils/gui/keyboard/test_modifiers.py b/tests/unit/sampletones_application/utils/gui/keyboard/test_modifiers.py
index 96a617850..e1fc84962 100644
--- a/tests/unit/sampletones_application/utils/gui/keyboard/test_modifiers.py
+++ b/tests/unit/sampletones_application/utils/gui/keyboard/test_modifiers.py
@@ -190,7 +190,7 @@ def test_the_super_key_leads_the_combination_it_is_part_of(
class TestSuperName(BaseTestSuite):
- """One key wears three names, so a combination reads the way the keyboard is labelled."""
+ """One key wears three names, so a combination reads the way the keyboard is labeled."""
@dataclass(frozen=True, kw_only=True)
class TestCase(BaseRegularTestCase):
diff --git a/tests/unit/sampletones_application/utils/gui/shortcuts/test_draft.py b/tests/unit/sampletones_application/utils/gui/shortcuts/test_draft.py
index 183e31beb..565893c34 100644
--- a/tests/unit/sampletones_application/utils/gui/shortcuts/test_draft.py
+++ b/tests/unit/sampletones_application/utils/gui/shortcuts/test_draft.py
@@ -145,9 +145,9 @@ class TestCase(BaseRegularTestCase):
),
TestCase(
label="a combination held in another category too",
- shortcut_id=ShortcutId.SAMPLES_MOVE_SAMPLE_UP,
+ shortcut_id=ShortcutId.VOICES_MOVE_VOICE_UP,
written="F2",
- holder=ShortcutId.SAMPLES_RENAME_SAMPLE,
+ holder=ShortcutId.VOICES_RENAME_VOICE,
),
)
diff --git a/tests/unit/sampletones_application/utils/gui/shortcuts/test_scheme.py b/tests/unit/sampletones_application/utils/gui/shortcuts/test_scheme.py
index 868689bf6..5efd6a592 100644
--- a/tests/unit/sampletones_application/utils/gui/shortcuts/test_scheme.py
+++ b/tests/unit/sampletones_application/utils/gui/shortcuts/test_scheme.py
@@ -109,10 +109,10 @@ def test_an_alias_claiming_another_action_s_combination_raises(self, rebound: Re
def test_one_combination_serves_a_category_of_its_own(self, rebound: RebindScheme) -> None:
"""Tab moves between dialog controls and between tracker columns, each in its own scope."""
- scheme = rebound({ShortcutId.SAMPLES_RENAME_SAMPLE: WrittenShortcut(combination="Tab")})
+ scheme = rebound({ShortcutId.VOICES_RENAME_VOICE: WrittenShortcut(combination="Tab")})
assert scheme.shortcut(ShortcutId.TRACKER_NEXT_COLUMN).display() == "Tab"
- assert scheme.shortcut(ShortcutId.SAMPLES_RENAME_SAMPLE).display() == "Tab"
+ assert scheme.shortcut(ShortcutId.VOICES_RENAME_VOICE).display() == "Tab"
def test_a_combination_naming_no_key_raises(self, rebound: RebindScheme) -> None:
with pytest.raises(KeyError):
@@ -127,7 +127,7 @@ def test_an_alias_resolves_to_the_action_it_extends(self, shipped: ShortcutSchem
assert shipped.action(ShortcutCategory.ORDER, _press("Num+")) is ShortcutId.ORDER_INSERT_FRAME
def test_a_press_the_category_leaves_unnamed_resolves_to_nothing(self, shipped: ShortcutScheme) -> None:
- assert shipped.action(ShortcutCategory.SAMPLES, _press("Ctrl+S")) is None
+ assert shipped.action(ShortcutCategory.VOICES, _press("Ctrl+S")) is None
def test_each_category_answers_a_shared_combination_with_its_own_action(
self,
@@ -160,7 +160,7 @@ def test_an_alias_reads_as_the_action_it_extends(self, shipped: ShortcutScheme)
assert claimant is ShortcutId.REDO
def test_a_combination_the_category_leaves_unclaimed_reads_as_nothing(self, shipped: ShortcutScheme) -> None:
- assert shipped.claimant(ShortcutCategory.SAMPLES, KeyCombination.parse("Ctrl+Z")) is None
+ assert shipped.claimant(ShortcutCategory.VOICES, KeyCombination.parse("Ctrl+Z")) is None
def test_each_category_answers_a_shared_combination_with_its_own_action(self, shipped: ShortcutScheme) -> None:
escape = KeyCombination.parse("Esc")
@@ -272,7 +272,7 @@ def test_an_override_taking_a_combination_another_category_holds_stands(self, sh
scheme = shipped.with_overrides({"AboutDialog": TABLE_COMBINATION})
assert scheme.action(ShortcutCategory.APPLICATION, _press(TABLE_COMBINATION)) is ShortcutId.ABOUT_DIALOG
- assert scheme.action(ShortcutCategory.SAMPLES, _press(TABLE_COMBINATION)) is ShortcutId.SAMPLES_REMOVE_SAMPLE
+ assert scheme.action(ShortcutCategory.VOICES, _press(TABLE_COMBINATION)) is ShortcutId.VOICES_REMOVE_VOICE
def test_an_override_stating_no_combination_leaves_the_action_unbound(self, shipped: ShortcutScheme) -> None:
scheme = shipped.with_overrides({"Undo": None})
diff --git a/tests/unit/sampletones_application/utils/gui/shortcuts/test_source.py b/tests/unit/sampletones_application/utils/gui/shortcuts/test_source.py
index 939a3532c..f1ef55ff2 100644
--- a/tests/unit/sampletones_application/utils/gui/shortcuts/test_source.py
+++ b/tests/unit/sampletones_application/utils/gui/shortcuts/test_source.py
@@ -45,10 +45,10 @@ def test_a_rebind_changes_what_a_scope_makes_of_a_press(
source: ShortcutSource,
rebound: RebindScheme,
) -> None:
- source.activate(rebound({ShortcutId.SAMPLES_RENAME_SAMPLE: WrittenShortcut(combination="F6")}))
+ source.activate(rebound({ShortcutId.VOICES_RENAME_VOICE: WrittenShortcut(combination="F6")}))
- assert source.action(ShortcutCategory.SAMPLES, _press("F6")) is ShortcutId.SAMPLES_RENAME_SAMPLE
- assert source.action(ShortcutCategory.SAMPLES, _press("F2")) is None
+ assert source.action(ShortcutCategory.VOICES, _press("F6")) is ShortcutId.VOICES_RENAME_VOICE
+ assert source.action(ShortcutCategory.VOICES, _press("F2")) is None
def test_activating_announces_the_scheme_now_in_place(
self,
diff --git a/tests/unit/sampletones_application/utils/gui/test_palette.py b/tests/unit/sampletones_application/utils/gui/test_palette.py
index d011a4305..158dfb161 100644
--- a/tests/unit/sampletones_application/utils/gui/test_palette.py
+++ b/tests/unit/sampletones_application/utils/gui/test_palette.py
@@ -48,7 +48,7 @@ def context() -> Generator[None, None, None]:
def _text_color(item: Sender) -> ColorRGBA:
- """The item's colour as eight-bit channels, which DearPyGui reports as fractions."""
+ """The item's color as eight-bit channels, which DearPyGui reports as fractions."""
configuration: Dict[str, object] = dpg.get_item_configuration(item)
color = configuration["color"]
assert isinstance(color, (list, tuple))
@@ -62,7 +62,7 @@ def _add_text() -> Sender:
class TestArgumentBinding:
- def test_the_colour_reaches_the_item_as_it_is_bound(
+ def test_the_color_reaches_the_item_as_it_is_bound(
self,
context: None,
accent: BaseColor,
@@ -88,7 +88,7 @@ def test_the_item_takes_the_newly_activated_palette(
assert _text_color(item) == LIGHT_ACCENT
- def test_a_literal_colour_stays_as_written(
+ def test_a_literal_color_stays_as_written(
self,
context: None,
source: PaletteSource,
@@ -102,12 +102,12 @@ def test_a_literal_colour_stays_as_written(
assert _text_color(item) == LITERAL
- def test_recolouring_one_argument_leaves_one_entry(
+ def test_recoloring_one_argument_leaves_one_entry(
self,
context: None,
accent: BaseColor,
) -> None:
- """A hovered item is recoloured on every frame it is under the pointer."""
+ """A hovered item is recolored on every frame it is under the pointer."""
item = _add_text()
for _ in range(5):
@@ -130,7 +130,7 @@ def test_a_deleted_item_is_dropped(
class TestThemeColorBinding:
- def test_the_theme_colour_takes_the_newly_activated_palette(
+ def test_the_theme_color_takes_the_newly_activated_palette(
self,
context: None,
source: PaletteSource,
@@ -146,7 +146,7 @@ def test_the_theme_colour_takes_the_newly_activated_palette(
assert tuple(int(channel) for channel in dpg.get_value(item)) == LIGHT_ACCENT
- def test_a_derived_colour_follows_the_colour_it_came_from(
+ def test_a_derived_color_follows_the_color_it_came_from(
self,
context: None,
source: PaletteSource,
diff --git a/tests/unit/sampletones_application/utils/palette/test_catalog.py b/tests/unit/sampletones_application/utils/palette/test_catalog.py
index 4e58edbc0..4df0262a8 100644
--- a/tests/unit/sampletones_application/utils/palette/test_catalog.py
+++ b/tests/unit/sampletones_application/utils/palette/test_catalog.py
@@ -77,3 +77,14 @@ def test_every_palette_declares_the_same_tokens(self, catalog: PaletteCatalog) -
expected = set(catalog.default.colors)
for name, palette in catalog.palettes.items():
assert set(palette.colors) == expected, f"Palette {name!r} token set differs from {DEFAULT_PALETTE_NAME!r}"
+
+ def test_every_palette_tells_the_two_voice_kinds_apart(self, catalog: PaletteCatalog) -> None:
+ """A recording and a hand-written voice wear their colors in the tracker and the list alike.
+
+ Each palette states the pair for itself, so a color that separates on one ground can go
+ dark and saturated on another and the kinds stay apart in all of them.
+ """
+ for name, palette in catalog.palettes.items():
+ assert (
+ palette.colors["voice_sample"] != palette.colors["voice_instrument"]
+ ), f"Palette {name!r} gives both voice kinds one color"
diff --git a/tests/unit/sampletones_application/utils/palette/test_colors.py b/tests/unit/sampletones_application/utils/palette/test_colors.py
index f90f20cf1..fc5784f2f 100644
--- a/tests/unit/sampletones_application/utils/palette/test_colors.py
+++ b/tests/unit/sampletones_application/utils/palette/test_colors.py
@@ -23,7 +23,7 @@ def accent(source: PaletteSource) -> BaseColor:
class TestComposedColor:
- """Fading, desaturating and mixing each answer with a colour that still reads the palette."""
+ """Fading, desaturating and mixing each answer with a color that still reads the palette."""
def test_fading_keeps_the_hue_and_sets_the_opacity(self, accent: BaseColor) -> None:
assert FadedColor(color=accent, fraction=0.5).rgba == (169, 127, 227, 128)
@@ -41,7 +41,7 @@ def test_mixing_lands_between_the_two_ends(self) -> None:
255,
)
- def test_a_composed_colour_answers_with_the_newly_activated_palette(
+ def test_a_composed_color_answers_with_the_newly_activated_palette(
self,
source: PaletteSource,
light: Palette,
@@ -69,11 +69,11 @@ def test_compositions_nest(
gray = round(0.299 * 107 + 0.587 * 63 + 0.114 * 176)
assert dimmed.rgba == (gray, gray, gray, 64)
- def test_the_same_composition_of_the_same_colour_is_one_value(
+ def test_the_same_composition_of_the_same_color_is_one_value(
self,
accent: BaseColor,
) -> None:
- """A theme cache keyed by colour holds one entry per shade the application draws."""
+ """A theme cache keyed by color holds one entry per shade the application draws."""
assert {
FadedColor(color=accent, fraction=0.5),
FadedColor(color=accent, fraction=0.5),
@@ -83,7 +83,7 @@ def test_the_same_composition_of_the_same_colour_is_one_value(
FadedColor(color=accent, fraction=0.25),
}
- def test_the_same_composition_of_two_colours_stays_two_values(
+ def test_the_same_composition_of_two_colors_stays_two_values(
self,
accent: BaseColor,
) -> None:
diff --git a/tests/unit/sampletones_application/utils/palette/test_palette.py b/tests/unit/sampletones_application/utils/palette/test_palette.py
index eb06d836a..96b7f64e9 100644
--- a/tests/unit/sampletones_application/utils/palette/test_palette.py
+++ b/tests/unit/sampletones_application/utils/palette/test_palette.py
@@ -19,7 +19,7 @@ def palette() -> Palette:
class TestPaletteResolution:
- def test_a_reference_resolves_to_its_token_colour(self, palette: Palette) -> None:
+ def test_a_reference_resolves_to_its_token_color(self, palette: Palette) -> None:
assert palette.resolve(PaletteReference(token="accent")) == (169, 127, 227, 255)
def test_an_alpha_override_replaces_only_the_alpha_channel(self, palette: Palette) -> None:
diff --git a/tests/unit/sampletones_application/utils/palette/test_written.py b/tests/unit/sampletones_application/utils/palette/test_written.py
index 4bd1981df..ec0926856 100644
--- a/tests/unit/sampletones_application/utils/palette/test_written.py
+++ b/tests/unit/sampletones_application/utils/palette/test_written.py
@@ -35,8 +35,8 @@ def test_a_reference_resolves_against_the_source_palette(self, source: PaletteSo
def test_a_reference_alpha_override_is_applied(self, source: PaletteSource) -> None:
assert _swatch(".accent/0.5", source).color.rgba == (169, 127, 227, 128)
- def test_a_colour_built_in_code_stands_as_it_is(self, source: PaletteSource) -> None:
- """A derived shade reaches a field as the colour it already is."""
+ def test_a_color_built_in_code_stands_as_it_is(self, source: PaletteSource) -> None:
+ """A derived shade reaches a field as the color it already is."""
color: BaseColor = FadedColor(
color=LiteralColor((240, 146, 86, 255)),
fraction=0.5,
diff --git a/tests/unit/sampletones_application/utils/parallelization/test_thread.py b/tests/unit/sampletones_application/utils/parallelization/test_thread.py
index 018becfea..da62a4332 100644
--- a/tests/unit/sampletones_application/utils/parallelization/test_thread.py
+++ b/tests/unit/sampletones_application/utils/parallelization/test_thread.py
@@ -4,7 +4,7 @@
from unittest.mock import patch
from sampletones_application.utils.parallelization.thread import (
- BackgroundWorkCancelled,
+ BackgroundWorkCanceled,
SingleThreadExecutor,
concurrent,
)
@@ -100,11 +100,11 @@ def work(self) -> None:
assert ran == []
- def test_cancelled_exception_unwinds_without_logging_an_error(self) -> None:
+ def test_canceled_exception_unwinds_without_logging_an_error(self) -> None:
class Worker:
@concurrent(wait=True)
def work(self) -> None:
- raise BackgroundWorkCancelled
+ raise BackgroundWorkCanceled
with patch("sampletones_application.utils.parallelization.thread.logger") as logger:
Worker().work()
diff --git a/tests/unit/sampletones_application/view_model/main/test_converter.py b/tests/unit/sampletones_application/view_model/main/test_converter.py
index 7a1f61aca..b638a04e8 100644
--- a/tests/unit/sampletones_application/view_model/main/test_converter.py
+++ b/tests/unit/sampletones_application/view_model/main/test_converter.py
@@ -1,12 +1,43 @@
from pathlib import Path
+from typing import Final, FrozenSet, Optional, Tuple
import pytest
+from sampletones_application.constants.conversion import MAX_STEM_SOURCES
from sampletones_application.view_model.main.converter import (
ConversionPhase,
ConverterAction,
ConverterViewModel,
)
+from sampletones_application.view_model.shared.stems import StemRowViewModel
+from sampletones_core.constants.enums import ChannelName, HierarchyMode
+
+ENABLED_CHANNELS: Final[FrozenSet[ChannelName]] = frozenset(
+ {ChannelName.PULSE1, ChannelName.TRIANGLE, ChannelName.NOISE}
+)
+
+
+def _row(
+ name: str,
+ *,
+ channels: FrozenSet[ChannelName] = ENABLED_CHANNELS,
+ level: int = 0,
+ position: int = 0,
+ level_size: int = 1,
+ level_count: int = 1,
+) -> StemRowViewModel:
+ path = Path(f"/audio/{name}.wav")
+ return StemRowViewModel(
+ key=str(path),
+ path=path,
+ channels=channels,
+ offered_channels=frozenset(ChannelName.items()),
+ available=True,
+ level=level,
+ position=position,
+ level_size=level_size,
+ level_count=level_count,
+ )
def _view_model(
@@ -14,16 +45,28 @@ def _view_model(
phase: ConversionPhase,
other_operation_active: bool = False,
progress: float = 0.0,
+ input_path: Optional[Path] = Path("/audio/sample.wav"),
+ stems_mode: bool = False,
+ stem_sources: Tuple[StemRowViewModel, ...] = (),
+ channel_cap: int = len(ENABLED_CHANNELS),
+ max_sources: int = MAX_STEM_SOURCES,
) -> ConverterViewModel:
return ConverterViewModel(
phase=phase,
status_text="",
action_label="",
progress=progress,
- input_path=Path("/audio/sample.wav"),
+ input_path=input_path,
output_path=Path("/reconstructions"),
is_file=True,
other_operation_active=other_operation_active,
+ stems_mode=stems_mode,
+ stem_sources=stem_sources,
+ enabled_channels=ENABLED_CHANNELS,
+ channel_cap=channel_cap,
+ max_channel_cap=len(ENABLED_CHANNELS),
+ hierarchy_mode=HierarchyMode.ROUND_ROBIN,
+ max_sources=max_sources,
)
@@ -73,7 +116,7 @@ class TestPrimaryAction:
(ConversionPhase.RUNNING, ConverterAction.CANCEL),
(ConversionPhase.CANCELLING, ConverterAction.CANCEL),
(ConversionPhase.COMPLETED, ConverterAction.CONVERT),
- (ConversionPhase.CANCELLED, ConverterAction.CONVERT),
+ (ConversionPhase.CANCELED, ConverterAction.CONVERT),
(ConversionPhase.FAILED, ConverterAction.CONVERT),
],
)
@@ -98,10 +141,89 @@ def test_cancel_enablement(self, phase: ConversionPhase, enabled: bool) -> None:
@pytest.mark.parametrize(
"phase",
- [ConversionPhase.COMPLETED, ConversionPhase.CANCELLED, ConversionPhase.FAILED],
+ [ConversionPhase.COMPLETED, ConversionPhase.CANCELED, ConversionPhase.FAILED],
)
def test_convert_disabled_in_terminal_phases(self, phase: ConversionPhase) -> None:
assert _view_model(phase=phase).primary_action_enabled is False
def test_convert_enabled_when_idle_with_input(self) -> None:
assert _view_model(phase=ConversionPhase.IDLE).primary_action_enabled is True
+
+
+class TestStemsSection:
+ """In stems mode the listed recordings are what there is to convert, and the list has a bound."""
+
+ def test_a_listed_recording_counts_as_an_input(self) -> None:
+ view_model = _view_model(
+ phase=ConversionPhase.IDLE,
+ input_path=None,
+ stems_mode=True,
+ stem_sources=(_row("bass"),),
+ )
+
+ assert view_model.has_input is True
+ assert view_model.source_count == 1
+ assert view_model.convert_button_enabled is True
+
+ def test_an_empty_list_offers_nothing_to_convert(self) -> None:
+ view_model = _view_model(phase=ConversionPhase.IDLE, stems_mode=True)
+
+ assert view_model.has_input is False
+ assert view_model.convert_button_enabled is False
+
+ def test_the_selected_path_carries_a_classic_conversion(self) -> None:
+ view_model = _view_model(phase=ConversionPhase.IDLE, stems_mode=False)
+
+ assert view_model.has_input is True
+
+ def test_a_full_list_takes_no_more(self) -> None:
+ rows = tuple(_row(str(index)) for index in range(MAX_STEM_SOURCES))
+ view_model = _view_model(phase=ConversionPhase.IDLE, stems_mode=True, stem_sources=rows)
+
+ assert view_model.can_add_source is False
+
+ def test_a_list_with_room_takes_another(self) -> None:
+ view_model = _view_model(phase=ConversionPhase.IDLE, stems_mode=True, stem_sources=(_row("bass"),))
+
+ assert view_model.can_add_source is True
+
+ def test_a_row_names_itself_by_its_recording(self) -> None:
+ assert _row("bass").name == "bass"
+
+ def test_a_row_holding_no_channel_offers_nothing_to_convert(self) -> None:
+ view_model = _view_model(
+ phase=ConversionPhase.IDLE,
+ input_path=None,
+ stems_mode=True,
+ stem_sources=(_row("bass", channels=frozenset()),),
+ )
+
+ assert view_model.has_input is False
+ assert view_model.playing_count == 0
+ assert view_model.convert_button_enabled is False
+
+
+class TestRowStanding:
+ """A row states where it stands, so the moves it offers gray themselves out from the row alone."""
+
+ def test_the_only_row_of_the_only_level_can_go_nowhere(self) -> None:
+ row = _row("bass")
+
+ assert row.is_first_on_level is True
+ assert row.is_last_on_level is True
+ assert row.alone_on_level is True
+ assert row.has_level_above is False
+ assert row.has_level_below is False
+
+ def test_a_row_between_levels_can_join_either(self) -> None:
+ row = _row("lead", level=1, level_count=3)
+
+ assert row.has_level_above is True
+ assert row.has_level_below is True
+
+ def test_a_row_sharing_a_level_names_its_place_among_its_peers(self) -> None:
+ row = _row("lead", position=1, level_size=3)
+
+ assert row.is_first_on_level is False
+ assert row.is_last_on_level is False
+ assert row.alone_on_level is False
diff --git a/tests/unit/sampletones_application/view_model/reconstruction/test_reconstruction.py b/tests/unit/sampletones_application/view_model/reconstruction/test_reconstruction.py
index 0a07b7869..c0d457848 100644
--- a/tests/unit/sampletones_application/view_model/reconstruction/test_reconstruction.py
+++ b/tests/unit/sampletones_application/view_model/reconstruction/test_reconstruction.py
@@ -1,12 +1,30 @@
from dataclasses import dataclass
+from pathlib import Path
import pytest
-from sampletones_application.view_model.reconstruction.reconstruction import (
- ReconstructionPathState,
+from sampletones_application.view_model.reconstruction.paths.path import (
ReconstructionPathViewModel,
+)
+from sampletones_application.view_model.reconstruction.paths.state import (
+ ReconstructionPathState,
+)
+from sampletones_application.view_model.reconstruction.reconstruction import (
ReconstructionViewModel,
)
+from sampletones_application.view_model.reconstruction.stems import (
+ ReconstructionStemsViewModel,
+)
+from sampletones_application.view_model.shared.stems import StemsListViewModel
+from sampletones_core.constants.enums import HierarchyMode
+
+EMPTY_STEMS = StemsListViewModel(
+ rows=(),
+ channels_in_play=(),
+ muted_channels=frozenset(),
+ live=True,
+ collapse_levels=False,
+)
@dataclass(frozen=True)
@@ -28,6 +46,14 @@ class EnablementCase:
locate_audio_enabled=True,
show_locate_audio_hint=False,
),
+ EnablementCase(
+ "stems_recorded",
+ original_audio_state=ReconstructionPathState.MULTIPLE,
+ reconstruction_loaded=True,
+ audio_source_enabled=True,
+ locate_audio_enabled=True,
+ show_locate_audio_hint=False,
+ ),
EnablementCase(
"audio_file_moved",
original_audio_state=ReconstructionPathState.NOT_FOUND,
@@ -71,12 +97,73 @@ def test_enablement_follows_original_audio_state(
) -> None:
view_model = ReconstructionViewModel(
reconstruction_loaded=case.reconstruction_loaded,
- playing_generators=frozenset(),
- selected_generators=frozenset(),
- reconstruction_file=ReconstructionPathViewModel(state=ReconstructionPathState.EMPTY, path=""),
- original_audio=ReconstructionPathViewModel(state=case.original_audio_state, path=""),
+ playing_channels=frozenset(),
+ selected_channels=frozenset(),
+ reconstruction_file=ReconstructionPathViewModel(state=ReconstructionPathState.EMPTY, paths=()),
+ original_audio=ReconstructionPathViewModel(state=case.original_audio_state, paths=()),
+ nes_frequency=None,
)
assert view_model.audio_source_enabled is case.audio_source_enabled
assert view_model.locate_audio_enabled is case.locate_audio_enabled
assert view_model.show_locate_audio_hint is case.show_locate_audio_hint
+
+
+class TestReconstructionPathViewModelPath:
+ def test_single_path_is_the_location(self) -> None:
+ view_model = ReconstructionPathViewModel(
+ state=ReconstructionPathState.AVAILABLE,
+ paths=("/songs/source.wav",),
+ )
+
+ assert view_model.path == "/songs/source.wav"
+
+ def test_several_paths_leave_no_single_location(self) -> None:
+ view_model = ReconstructionPathViewModel(
+ state=ReconstructionPathState.MULTIPLE,
+ paths=("/a/one.wav", "/b/two.wav"),
+ )
+
+ assert view_model.path == ""
+
+
+class TestReconstructionPathStateFromSourcePaths:
+ def test_no_paths_are_not_applicable(self) -> None:
+ assert ReconstructionPathState.from_source_paths(()) is ReconstructionPathState.NOT_APPLICABLE
+
+ def test_one_path_is_available(self) -> None:
+ assert (
+ ReconstructionPathState.from_source_paths((Path("/songs/source.wav"),)) is ReconstructionPathState.AVAILABLE
+ )
+
+ def test_several_paths_are_multiple(self) -> None:
+ paths = (Path("/a/one.wav"), Path("/b/two.wav"))
+
+ assert ReconstructionPathState.from_source_paths(paths) is ReconstructionPathState.MULTIPLE
+
+
+class TestReconstructionStemsViewModel:
+ def test_the_setup_line_follows_the_stems_record(self) -> None:
+ stems = ReconstructionStemsViewModel(
+ reconstruction_loaded=True,
+ stems=EMPTY_STEMS,
+ hierarchy_mode=HierarchyMode.STRICT,
+ channel_cap=2,
+ )
+
+ assert stems.show_setup_line
+ assert stems.channel_cap == 2
+
+ def test_the_empty_state_names_a_loaded_reconstruction_with_no_source(self) -> None:
+ loaded = ReconstructionStemsViewModel(
+ reconstruction_loaded=True,
+ stems=EMPTY_STEMS,
+ )
+ closed = ReconstructionStemsViewModel(
+ reconstruction_loaded=False,
+ stems=EMPTY_STEMS,
+ )
+
+ assert loaded.show_empty_state
+ assert not loaded.show_setup_line
+ assert not closed.show_empty_state
diff --git a/tests/unit/sampletones_application/view_model/sequencer/test_channels.py b/tests/unit/sampletones_application/view_model/sequencer/test_channels.py
index b76b16938..679b4345a 100644
--- a/tests/unit/sampletones_application/view_model/sequencer/test_channels.py
+++ b/tests/unit/sampletones_application/view_model/sequencer/test_channels.py
@@ -4,22 +4,22 @@
import pytest
from sampletones_application.view_model.sequencer.channels import SequencerChannelsViewModel
-from sampletones_core.constants.enums import GeneratorName
+from sampletones_core.constants.enums import ChannelName
from tests.suite.base import BaseTestSuite
from tests.suite.case import BaseRegularTestCase
-ALL_CHANNELS = frozenset(GeneratorName.items())
+ALL_CHANNELS = frozenset(ChannelName.items())
-PULSE1 = GeneratorName.PULSE1
-PULSE2 = GeneratorName.PULSE2
-TRIANGLE = GeneratorName.TRIANGLE
-NOISE = GeneratorName.NOISE
+PULSE1 = ChannelName.PULSE1
+PULSE2 = ChannelName.PULSE2
+TRIANGLE = ChannelName.TRIANGLE
+NOISE = ChannelName.NOISE
class TestAllMuted(BaseTestSuite):
@dataclass(frozen=True, kw_only=True)
class AllMutedCase(BaseRegularTestCase):
- muted: FrozenSet[GeneratorName]
+ muted: FrozenSet[ChannelName]
expected: bool
test_cases = (
@@ -55,7 +55,7 @@ def test_all_muted_reports_full_silence(self, case: AllMutedCase) -> None:
class TestAnyMuted(BaseTestSuite):
@dataclass(frozen=True, kw_only=True)
class AllMutedCase(BaseRegularTestCase):
- muted: FrozenSet[GeneratorName]
+ muted: FrozenSet[ChannelName]
expected: bool
test_cases = (
@@ -98,29 +98,29 @@ def test_the_two_readings_agree_in_full_silence(self) -> None:
class TestIsSoloed:
@pytest.mark.parametrize(
- "generator",
- GeneratorName.items(),
- ids=lambda generator: generator.value,
+ "channel",
+ ChannelName.items(),
+ ids=lambda channel: channel.value,
)
def test_the_one_audible_channel_reads_as_soloed(
self,
- generator: GeneratorName,
+ channel: ChannelName,
) -> None:
- view_model = SequencerChannelsViewModel(muted=ALL_CHANNELS - {generator})
+ view_model = SequencerChannelsViewModel(muted=ALL_CHANNELS - {channel})
- soloed = {other for other in GeneratorName.items() if view_model.is_soloed(other)}
+ soloed = {other for other in ChannelName.items() if view_model.is_soloed(other)}
- assert soloed == {generator}
+ assert soloed == {channel}
def test_no_channel_is_soloed_in_a_full_mix(self) -> None:
view_model = SequencerChannelsViewModel(muted=frozenset())
- assert not any(view_model.is_soloed(generator) for generator in GeneratorName.items())
+ assert not any(view_model.is_soloed(channel) for channel in ChannelName.items())
def test_no_channel_is_soloed_in_full_silence(self) -> None:
view_model = SequencerChannelsViewModel(muted=ALL_CHANNELS)
- assert not any(view_model.is_soloed(generator) for generator in GeneratorName.items())
+ assert not any(view_model.is_soloed(channel) for channel in ChannelName.items())
def test_two_audible_channels_leave_neither_soloed(self) -> None:
view_model = SequencerChannelsViewModel(muted=frozenset({PULSE1, PULSE2}))
@@ -131,19 +131,19 @@ def test_two_audible_channels_leave_neither_soloed(self) -> None:
class TestIsMuted:
@pytest.mark.parametrize(
- "generator",
- GeneratorName.items(),
- ids=lambda generator: generator.value,
+ "channel",
+ ChannelName.items(),
+ ids=lambda channel: channel.value,
)
def test_is_muted_reports_membership_of_the_mute_set(
self,
- generator: GeneratorName,
+ channel: ChannelName,
) -> None:
view_model = SequencerChannelsViewModel(muted=frozenset({TRIANGLE, NOISE}))
- assert view_model.is_muted(generator) is (generator in {TRIANGLE, NOISE})
+ assert view_model.is_muted(channel) is (channel in {TRIANGLE, NOISE})
def test_no_channel_is_muted_in_a_full_mix(self) -> None:
view_model = SequencerChannelsViewModel(muted=frozenset())
- assert not any(view_model.is_muted(generator) for generator in GeneratorName.items())
+ assert not any(view_model.is_muted(channel) for channel in ChannelName.items())
diff --git a/tests/unit/sampletones_application/view_model/sequencer/test_order.py b/tests/unit/sampletones_application/view_model/sequencer/test_order.py
index 678d2c8d7..dab1768d1 100644
--- a/tests/unit/sampletones_application/view_model/sequencer/test_order.py
+++ b/tests/unit/sampletones_application/view_model/sequencer/test_order.py
@@ -8,7 +8,7 @@
SequencerOrderTrackerViewModel,
SequencerOrderViewModel,
)
-from sampletones_core.constants.enums import GeneratorName
+from sampletones_core.constants.enums import ChannelName
from sampletones_core.utils.display import display_id
from sampletones_shared.constants.symbols import MIXED
from tests.suite.base import BaseTestSuite
@@ -18,11 +18,11 @@
def _tracker(
- channels: Dict[GeneratorName, List[Optional[int]]],
+ channels: Dict[ChannelName, List[Optional[int]]],
) -> SequencerOrderTrackerViewModel:
views = {
- generator: SequencerOrderViewModel(
- generator=generator,
+ channel: SequencerOrderViewModel(
+ channel=channel,
entries=tuple(
OrderEntryViewModel(
position=position,
@@ -31,7 +31,7 @@ def _tracker(
for position, index in enumerate(indices)
),
)
- for generator, indices in channels.items()
+ for channel, indices in channels.items()
}
position_count = max(
(len(view.entries) for view in views.values()),
@@ -43,22 +43,22 @@ def _tracker(
)
-def _uniform(*indices: Optional[int]) -> Dict[GeneratorName, List[Optional[int]]]:
- return {generator: list(indices) for generator in GeneratorName.items()}
+def _uniform(*indices: Optional[int]) -> Dict[ChannelName, List[Optional[int]]]:
+ return {channel: list(indices) for channel in ChannelName.items()}
def test_entry_label_renders_index_and_empty_slot() -> None:
tracker = _tracker(_uniform(5, None))
- assert tracker.entry_label(GeneratorName.PULSE1, 0) == display_id(5)
- assert tracker.entry_label(GeneratorName.PULSE1, 1) == _EMPTY
+ assert tracker.entry_label(ChannelName.PULSE1, 0) == display_id(5)
+ assert tracker.entry_label(ChannelName.PULSE1, 1) == _EMPTY
class TestMasterLabel(BaseTestSuite):
@dataclass(frozen=True, kw_only=True)
class MasterCase(BaseRegularTestCase):
label: str
- channels: Dict[GeneratorName, List[Optional[int]]]
+ channels: Dict[ChannelName, List[Optional[int]]]
expected: str
test_cases = (
@@ -75,20 +75,20 @@ class MasterCase(BaseRegularTestCase):
MasterCase(
label="divergent_index",
channels={
- GeneratorName.PULSE1: [5],
- GeneratorName.PULSE2: [5],
- GeneratorName.TRIANGLE: [7],
- GeneratorName.NOISE: [5],
+ ChannelName.PULSE1: [5],
+ ChannelName.PULSE2: [5],
+ ChannelName.TRIANGLE: [7],
+ ChannelName.NOISE: [5],
},
expected=MIXED,
),
MasterCase(
label="index_versus_empty",
channels={
- GeneratorName.PULSE1: [5],
- GeneratorName.PULSE2: [5],
- GeneratorName.TRIANGLE: [None],
- GeneratorName.NOISE: [5],
+ ChannelName.PULSE1: [5],
+ ChannelName.PULSE2: [5],
+ ChannelName.TRIANGLE: [None],
+ ChannelName.NOISE: [5],
},
expected=MIXED,
),
diff --git a/tests/unit/sampletones_application/view_model/sequencer/test_region.py b/tests/unit/sampletones_application/view_model/sequencer/test_region.py
index ebd29edab..3a1debb7b 100644
--- a/tests/unit/sampletones_application/view_model/sequencer/test_region.py
+++ b/tests/unit/sampletones_application/view_model/sequencer/test_region.py
@@ -14,7 +14,7 @@
slot_from_flat,
)
from sampletones_application.view_model.sequencer.subcolumn import SubColumn
-from sampletones_core.constants.enums import GeneratorName
+from sampletones_core.constants.enums import ChannelName
class TestTrackerRegion:
@@ -22,7 +22,7 @@ def test_a_single_cell_region_covers_that_cell(self) -> None:
region = TrackerRegion(first_row=3, last_row=3, first_slot=4, last_slot=4)
assert tuple(region.rows) == (3,)
- assert region.slots == (TrackerSlot(GeneratorName.PULSE1, SubColumn.TRANSPOSE),)
+ assert region.slots == (TrackerSlot(ChannelName.PULSE1, SubColumn.TRANSPOSE),)
def test_the_slots_read_as_the_columns_and_subcolumns_they_address(self) -> None:
"""A region's edges are subcolumns, so a run reaches across a column boundary mid-cell."""
@@ -30,7 +30,7 @@ def test_the_slots_read_as_the_columns_and_subcolumns_they_address(self) -> None
assert region.slots == (
TrackerSlot(None, SubColumn.VOLUME),
- TrackerSlot(GeneratorName.PULSE1, SubColumn.INSTRUMENT),
+ TrackerSlot(ChannelName.PULSE1, SubColumn.VOICE),
)
def test_a_region_spans_the_whole_axis(self) -> None:
@@ -61,13 +61,13 @@ class TestOrderRegion:
def test_a_single_cell_region_covers_that_cell(self) -> None:
region = OrderRegion(first_row=0, last_row=0, first_position=2, last_position=2)
- assert region.generators == (None,)
+ assert region.channels == (None,)
assert tuple(region.positions) == (2,)
def test_the_rows_read_as_the_channels_they_address(self) -> None:
region = OrderRegion(first_row=0, last_row=2, first_position=0, last_position=0)
- assert region.generators == (None, GeneratorName.PULSE1, GeneratorName.PULSE2)
+ assert region.channels == (None, ChannelName.PULSE1, ChannelName.PULSE2)
def test_a_region_spans_the_whole_channel_axis(self) -> None:
region = OrderRegion(
@@ -77,7 +77,7 @@ def test_a_region_spans_the_whole_channel_axis(self) -> None:
last_position=7,
)
- assert region.generators == CHANNEL_AXIS
+ assert region.channels == CHANNEL_AXIS
assert tuple(region.positions) == tuple(range(8))
def test_inverted_positions_are_rejected(self) -> None:
@@ -141,34 +141,34 @@ def region(self) -> OrderRegion:
return OrderRegion(first_row=1, last_row=2, first_position=3, last_position=6)
@pytest.mark.parametrize(
- ("generator", "position"),
+ ("channel", "position"),
[
- (GeneratorName.PULSE1, 3),
- (GeneratorName.PULSE2, 6),
- (GeneratorName.PULSE1, 5),
+ (ChannelName.PULSE1, 3),
+ (ChannelName.PULSE2, 6),
+ (ChannelName.PULSE1, 5),
],
)
def test_a_cell_inside_the_rectangle_belongs_to_it(
self,
region: OrderRegion,
- generator: GeneratorName,
+ channel: ChannelName,
position: int,
) -> None:
- assert region.covers(generator, position) is True
+ assert region.covers(channel, position) is True
@pytest.mark.parametrize(
- ("generator", "position"),
+ ("channel", "position"),
[
(None, 5),
- (GeneratorName.TRIANGLE, 5),
- (GeneratorName.PULSE1, 2),
- (GeneratorName.PULSE1, 7),
+ (ChannelName.TRIANGLE, 5),
+ (ChannelName.PULSE1, 2),
+ (ChannelName.PULSE1, 7),
],
)
def test_a_cell_outside_the_rectangle_stands_on_its_own(
self,
region: OrderRegion,
- generator: Optional[GeneratorName],
+ channel: Optional[ChannelName],
position: int,
) -> None:
- assert region.covers(generator, position) is False
+ assert region.covers(channel, position) is False
diff --git a/tests/unit/sampletones_application/view_model/sequencer/test_slot.py b/tests/unit/sampletones_application/view_model/sequencer/test_slot.py
index 34fb7cc8d..cc526609d 100644
--- a/tests/unit/sampletones_application/view_model/sequencer/test_slot.py
+++ b/tests/unit/sampletones_application/view_model/sequencer/test_slot.py
@@ -11,14 +11,14 @@
slot_from_flat,
)
from sampletones_application.view_model.sequencer.subcolumn import SubColumn
-from sampletones_core.constants.enums import GeneratorName
+from sampletones_core.constants.enums import ChannelName
_OUT_OF_RANGE = [-1, -SLOT_COUNT, SLOT_COUNT, SLOT_COUNT + 1]
class TestAxis:
def test_the_sample_column_leads_the_four_channels(self) -> None:
- assert CHANNEL_AXIS == (None, *GeneratorName.items())
+ assert CHANNEL_AXIS == (None, *ChannelName.items())
def test_the_axis_covers_every_column_once_over(self) -> None:
assert SLOT_COUNT == len(CHANNEL_AXIS) * len(SUBCOLUMNS)
@@ -30,25 +30,23 @@ def test_every_index_round_trips_through_its_slot(self, index: int) -> None:
assert slot_from_flat(index).flat_index == index
def test_the_axis_maps_onto_the_whole_index_range(self) -> None:
- indices = {
- TrackerSlot(generator, subcolumn).flat_index for generator in CHANNEL_AXIS for subcolumn in SUBCOLUMNS
- }
+ indices = {TrackerSlot(channel, subcolumn).flat_index for channel in CHANNEL_AXIS for subcolumn in SUBCOLUMNS}
assert indices == set(range(SLOT_COUNT))
def test_the_sample_columns_instrument_opens_the_axis(self) -> None:
- assert TrackerSlot(None, SubColumn.INSTRUMENT).flat_index == 0
+ assert TrackerSlot(None, SubColumn.VOICE).flat_index == 0
class TestColumnBase:
- @pytest.mark.parametrize("generator", CHANNEL_AXIS)
- def test_every_base_starts_a_whole_column(self, generator: Optional[GeneratorName]) -> None:
+ @pytest.mark.parametrize("channel", CHANNEL_AXIS)
+ def test_every_base_starts_a_whole_column(self, channel: Optional[ChannelName]) -> None:
"""Kind alignment rests on this: an offset from any base addresses the same subcolumn."""
- assert column_slot_base(generator) % len(SUBCOLUMNS) == 0
+ assert column_slot_base(channel) % len(SUBCOLUMNS) == 0
- @pytest.mark.parametrize("generator", CHANNEL_AXIS)
- def test_a_base_addresses_its_columns_first_subcolumn(self, generator: Optional[GeneratorName]) -> None:
- assert slot_from_flat(column_slot_base(generator)) == TrackerSlot(generator, SUBCOLUMNS[0])
+ @pytest.mark.parametrize("channel", CHANNEL_AXIS)
+ def test_a_base_addresses_its_columns_first_subcolumn(self, channel: Optional[ChannelName]) -> None:
+ assert slot_from_flat(column_slot_base(channel)) == TrackerSlot(channel, SUBCOLUMNS[0])
class TestBounds:
diff --git a/tests/unit/sampletones_application/view_model/sequencer/test_tracker.py b/tests/unit/sampletones_application/view_model/sequencer/test_tracker.py
index e9dd5c3ad..59a232b55 100644
--- a/tests/unit/sampletones_application/view_model/sequencer/test_tracker.py
+++ b/tests/unit/sampletones_application/view_model/sequencer/test_tracker.py
@@ -1,5 +1,5 @@
from dataclasses import dataclass
-from typing import Dict, FrozenSet
+from typing import Dict, FrozenSet, Optional
import pytest
@@ -7,7 +7,8 @@
SequencerCellViewModel,
SequencerRowViewModel,
)
-from sampletones_core.constants.enums import GeneratorName
+from sampletones_application.view_model.sequencer.voices import VoiceKind
+from sampletones_core.constants.enums import ChannelName
from sampletones_core.utils.display import (
NOTE_OFF,
display_id,
@@ -18,21 +19,23 @@
from tests.suite.base import BaseTestSuite
from tests.suite.case import BaseRegularTestCase
-_EMPTY_INSTRUMENT = display_id(None)
+_EMPTY_VOICE = display_id(None)
_EMPTY_TRANSPOSE = display_transpose(None)
_EMPTY_VOLUME = display_volume(None)
def _cell(
*,
- instrument: str = _EMPTY_INSTRUMENT,
+ voice: str = _EMPTY_VOICE,
transpose: str = _EMPTY_TRANSPOSE,
volume: str = _EMPTY_VOLUME,
+ kind: Optional[VoiceKind] = None,
) -> SequencerCellViewModel:
return SequencerCellViewModel(
- instrument=instrument,
+ voice=voice,
transpose=transpose,
volume=volume,
+ kind=kind,
)
@@ -41,18 +44,19 @@ def _empty_cell() -> SequencerCellViewModel:
_OCCUPIED = _cell(
- instrument=display_id(0),
+ voice=display_id(0),
transpose=display_transpose(5),
volume=display_volume(8),
+ kind=VoiceKind.SAMPLE,
)
def _row_cells(
**overrides: SequencerCellViewModel,
-) -> Dict[GeneratorName, SequencerCellViewModel]:
- cells = {generator: _empty_cell() for generator in GeneratorName.items()}
+) -> Dict[ChannelName, SequencerCellViewModel]:
+ cells = {channel: _empty_cell() for channel in ChannelName.items()}
for name, cell in overrides.items():
- cells[GeneratorName[name.upper()]] = cell
+ cells[ChannelName[name.upper()]] = cell
return cells
@@ -60,128 +64,227 @@ def _row_cells(
class TestSampleColumnAggregate(BaseTestSuite):
@dataclass(frozen=True, kw_only=True)
class AggregateCase(BaseRegularTestCase):
- cells: Dict[GeneratorName, SequencerCellViewModel]
- relevant_generators: FrozenSet[GeneratorName]
- expected_instrument: str
+ cells: Dict[ChannelName, SequencerCellViewModel]
+ sample_channels: FrozenSet[ChannelName]
+ expected_sample: str
expected_transpose: str
expected_volume: str
test_cases = (
AggregateCase(
- label="no_relevant_channels_fall_back_to_defaults",
+ label="no_sample_channels_fall_back_to_defaults",
cells=_row_cells(),
- relevant_generators=frozenset(),
- expected_instrument=_EMPTY_INSTRUMENT,
+ sample_channels=frozenset(),
+ expected_sample=_EMPTY_VOICE,
expected_transpose=_EMPTY_TRANSPOSE,
expected_volume=_EMPTY_VOLUME,
),
AggregateCase(
label="transpose_and_volume_span_all_channels_when_no_sample_is_present",
- cells={generator: _cell(volume=display_volume(8)) for generator in GeneratorName.items()},
- relevant_generators=frozenset(),
- expected_instrument=_EMPTY_INSTRUMENT,
+ cells={channel: _cell(volume=display_volume(8)) for channel in ChannelName.items()},
+ sample_channels=frozenset(),
+ expected_sample=_EMPTY_VOICE,
expected_transpose=_EMPTY_TRANSPOSE,
expected_volume=display_volume(8),
),
AggregateCase(
- label="single_relevant_channel_present",
+ label="a_single_sample_channel_present",
cells=_row_cells(pulse1=_OCCUPIED),
- relevant_generators=frozenset({GeneratorName.PULSE1}),
- expected_instrument=display_id(0),
+ sample_channels=frozenset({ChannelName.PULSE1}),
+ expected_sample=display_id(0),
expected_transpose=display_transpose(5),
expected_volume=display_volume(8),
),
AggregateCase(
- label="sample_present_across_all_its_relevant_channels",
+ label="a_sample_present_across_every_channel_it_covers",
cells=_row_cells(pulse1=_OCCUPIED, triangle=_OCCUPIED),
- relevant_generators=frozenset(
+ sample_channels=frozenset(
{
- GeneratorName.PULSE1,
- GeneratorName.TRIANGLE,
+ ChannelName.PULSE1,
+ ChannelName.TRIANGLE,
}
),
- expected_instrument=display_id(0),
+ expected_sample=display_id(0),
expected_transpose=display_transpose(5),
expected_volume=display_volume(8),
),
AggregateCase(
- label="sample_missing_from_one_relevant_channel_is_mixed",
+ label="a_sample_missing_from_one_of_its_channels_is_mixed",
cells=_row_cells(pulse1=_OCCUPIED),
- relevant_generators=frozenset(
+ sample_channels=frozenset(
{
- GeneratorName.PULSE1,
- GeneratorName.TRIANGLE,
+ ChannelName.PULSE1,
+ ChannelName.TRIANGLE,
}
),
- expected_instrument=MIXED,
+ expected_sample=MIXED,
expected_transpose=MIXED,
expected_volume=MIXED,
),
AggregateCase(
- label="diverging_transpose_is_mixed_while_instrument_is_uniform",
+ label="diverging_transpose_is_mixed_while_the_sample_is_uniform",
cells=_row_cells(
pulse1=_OCCUPIED,
triangle=_cell(
- instrument=display_id(0),
+ voice=display_id(0),
transpose=_EMPTY_TRANSPOSE,
volume=display_volume(8),
),
),
- relevant_generators=frozenset(
+ sample_channels=frozenset(
{
- GeneratorName.PULSE1,
- GeneratorName.TRIANGLE,
+ ChannelName.PULSE1,
+ ChannelName.TRIANGLE,
}
),
- expected_instrument=display_id(0),
+ expected_sample=display_id(0),
expected_transpose=MIXED,
expected_volume=display_volume(8),
),
+ AggregateCase(
+ label="an_instrument_alone_on_a_row_leaves_the_sample_column_empty",
+ cells=_row_cells(
+ pulse1=_cell(
+ voice=display_id(3),
+ kind=VoiceKind.INSTRUMENT,
+ ),
+ ),
+ sample_channels=frozenset(),
+ expected_sample=_EMPTY_VOICE,
+ expected_transpose=_EMPTY_TRANSPOSE,
+ expected_volume=_EMPTY_VOLUME,
+ ),
+ AggregateCase(
+ label="an_instrument_beside_a_sample_leaves_the_samples_reading_alone",
+ cells=_row_cells(
+ pulse1=_OCCUPIED,
+ triangle=_OCCUPIED,
+ noise=_cell(
+ voice=display_id(3),
+ kind=VoiceKind.INSTRUMENT,
+ ),
+ ),
+ sample_channels=frozenset(
+ {
+ ChannelName.PULSE1,
+ ChannelName.TRIANGLE,
+ }
+ ),
+ expected_sample=display_id(0),
+ expected_transpose=display_transpose(5),
+ expected_volume=display_volume(8),
+ ),
AggregateCase(
label="all_channels_note_off_reads_as_note_off",
- cells={generator: _cell(instrument=NOTE_OFF) for generator in GeneratorName.items()},
- relevant_generators=frozenset(),
- expected_instrument=NOTE_OFF,
+ cells={channel: _cell(voice=NOTE_OFF) for channel in ChannelName.items()},
+ sample_channels=frozenset(),
+ expected_sample=NOTE_OFF,
expected_transpose=_EMPTY_TRANSPOSE,
expected_volume=_EMPTY_VOLUME,
),
AggregateCase(
label="half_cut_row_is_mixed",
- cells=_row_cells(pulse1=_cell(instrument=NOTE_OFF)),
- relevant_generators=frozenset(),
- expected_instrument=MIXED,
+ cells=_row_cells(pulse1=_cell(voice=NOTE_OFF)),
+ sample_channels=frozenset(),
+ expected_sample=MIXED,
expected_transpose=_EMPTY_TRANSPOSE,
expected_volume=_EMPTY_VOLUME,
),
AggregateCase(
label="zero_transpose_beside_an_empty_one_is_mixed",
cells=_row_cells(pulse1=_cell(transpose=display_transpose(0))),
- relevant_generators=frozenset(),
- expected_instrument=_EMPTY_INSTRUMENT,
+ sample_channels=frozenset(),
+ expected_sample=_EMPTY_VOICE,
expected_transpose=MIXED,
expected_volume=_EMPTY_VOLUME,
),
AggregateCase(
label="zero_transpose_shared_by_every_channel_reads_as_zero",
- cells={generator: _cell(transpose=display_transpose(0)) for generator in GeneratorName.items()},
- relevant_generators=frozenset(),
- expected_instrument=_EMPTY_INSTRUMENT,
+ cells={channel: _cell(transpose=display_transpose(0)) for channel in ChannelName.items()},
+ sample_channels=frozenset(),
+ expected_sample=_EMPTY_VOICE,
expected_transpose=display_transpose(0),
expected_volume=_EMPTY_VOLUME,
),
)
@pytest.mark.parametrize("case", test_cases, ids=lambda case: case.label)
- def test_sample_column_aggregates_over_relevant_channels(
+ def test_sample_column_aggregates_over_the_channels_it_spans(
self,
case: AggregateCase,
) -> None:
row = SequencerRowViewModel(
index=0,
cells=case.cells,
- relevant_generators=case.relevant_generators,
+ sample_channels=case.sample_channels,
+ )
+
+ assert row.sample == case.expected_sample
+ assert row.transpose == case.expected_transpose
+ assert row.volume == case.expected_volume
+
+
+class TestWhichKindTheSampleColumnNames:
+ """The slot's kind is what colors it, so it states one only where its channels agree."""
+
+ @staticmethod
+ def _row(
+ cells: Dict[ChannelName, SequencerCellViewModel],
+ sample_channels: FrozenSet[ChannelName],
+ ) -> SequencerRowViewModel:
+ return SequencerRowViewModel(
+ index=0,
+ cells=cells,
+ sample_channels=sample_channels,
+ )
+
+ def test_a_row_naming_nothing_states_no_kind(self) -> None:
+ row = self._row(_row_cells(), frozenset())
+
+ assert row.sample_kind is None
+
+ def test_a_sample_across_its_channels_states_the_sample_kind(self) -> None:
+ row = self._row(
+ _row_cells(pulse1=_OCCUPIED, triangle=_OCCUPIED),
+ frozenset({ChannelName.PULSE1, ChannelName.TRIANGLE}),
+ )
+
+ assert row.sample_kind is VoiceKind.SAMPLE
+
+ def test_a_sample_missing_from_one_of_its_channels_states_no_kind(self) -> None:
+ """The reading is mixed there, and a mixed cell speaks for no one voice."""
+ row = self._row(
+ _row_cells(pulse1=_OCCUPIED),
+ frozenset({ChannelName.PULSE1, ChannelName.TRIANGLE}),
+ )
+
+ assert row.sample_kind is None
+
+ def test_an_instrument_alone_on_a_row_states_no_kind(self) -> None:
+ """It is placed in its own channel column, so the sample column speaks for none of it."""
+ row = self._row(
+ _row_cells(pulse1=_cell(voice=display_id(3), kind=VoiceKind.INSTRUMENT)),
+ frozenset(),
+ )
+
+ assert row.sample_kind is None
+
+ def test_an_instrument_beside_a_sample_leaves_the_sample_kind_standing(self) -> None:
+ row = self._row(
+ _row_cells(
+ pulse1=_OCCUPIED,
+ noise=_cell(voice=display_id(3), kind=VoiceKind.INSTRUMENT),
+ ),
+ frozenset({ChannelName.PULSE1}),
+ )
+
+ assert row.sample_kind is VoiceKind.SAMPLE
+
+ def test_a_cut_row_states_no_kind(self) -> None:
+ """A cut names no voice, so the slot reads it in the shade an empty one takes."""
+ row = self._row(
+ {channel: _cell(voice=NOTE_OFF) for channel in ChannelName.items()},
+ frozenset(),
)
- assert row.sample_instrument == case.expected_instrument
- assert row.sample_transpose == case.expected_transpose
- assert row.sample_volume == case.expected_volume
+ assert row.sample_kind is None
diff --git a/tests/unit/sampletones_application/view_model/sequencer/test_samples.py b/tests/unit/sampletones_application/view_model/sequencer/test_voices.py
similarity index 65%
rename from tests/unit/sampletones_application/view_model/sequencer/test_samples.py
rename to tests/unit/sampletones_application/view_model/sequencer/test_voices.py
index 3cf52ff50..14a0f2694 100644
--- a/tests/unit/sampletones_application/view_model/sequencer/test_samples.py
+++ b/tests/unit/sampletones_application/view_model/sequencer/test_voices.py
@@ -1,9 +1,9 @@
import pytest
-from sampletones_application.view_model.sequencer.samples import SampleSelection
+from sampletones_application.view_model.sequencer.voices import VoiceKind, VoiceSelection
-class TestSampleSelectionLabel:
+class TestVoiceSelectionLabel:
@pytest.mark.parametrize(
("position", "name", "expected"),
[
@@ -18,6 +18,6 @@ def test_label_pairs_the_hex_position_with_the_name(
name: str,
expected: str,
) -> None:
- selection = SampleSelection(sample_id="id", position=position, name=name)
+ selection = VoiceSelection(voice_id="id", position=position, name=name, kind=VoiceKind.SAMPLE)
assert selection.label == expected
diff --git a/tests/unit/sampletones_application/view_model/shared/test_export.py b/tests/unit/sampletones_application/view_model/shared/test_export.py
new file mode 100644
index 000000000..c20f47912
--- /dev/null
+++ b/tests/unit/sampletones_application/view_model/shared/test_export.py
@@ -0,0 +1,92 @@
+from typing import Final, Tuple
+
+from sampletones_application.view_model.shared.export import (
+ ExportPhase,
+ SongExportViewModel,
+)
+from sampletones_core.exports.stage import ExportStage
+
+HALFWAY: Final[float] = 0.5
+NO_PROGRESS: Final[float] = 0.0
+SIZE: Final[str] = "8761 of 32429 bytes"
+
+
+def view_model(
+ *,
+ phase: ExportPhase = ExportPhase.EXPORTING,
+ stages: Tuple[ExportStage, ...] = (ExportStage.WALKING,),
+ figure: str = "",
+ progress: float = HALFWAY,
+ traveling: bool = True,
+) -> SongExportViewModel:
+ return SongExportViewModel(
+ phase=phase,
+ stages=stages,
+ figure=figure,
+ progress=progress,
+ traveling=traveling,
+ )
+
+
+class TestWhatTheDialogOpensOn:
+ """A window with no run behind it draws nothing and offers no stop."""
+
+ def test_an_idle_dialog_lists_no_stage(self) -> None:
+ assert SongExportViewModel.idle().stages == ()
+
+ def test_an_idle_dialog_is_not_active(self) -> None:
+ assert SongExportViewModel.idle().is_active is False
+
+ def test_an_idle_dialog_offers_no_stop(self) -> None:
+ assert SongExportViewModel.idle().cancel_enabled is False
+
+
+class TestWhereTheRunStands:
+ """The stage at the foot of the list is the one under way; the rest are behind it."""
+
+ def test_the_last_stage_reached_is_the_one_under_way(self) -> None:
+ reached = (ExportStage.WALKING, ExportStage.COMPRESSING)
+ assert view_model(stages=reached).stage == ExportStage.COMPRESSING
+
+ def test_a_run_that_has_named_nothing_has_no_stage_under_way(self) -> None:
+ assert view_model(stages=()).stage is None
+
+ def test_a_stage_the_run_has_left_reads_as_behind(self) -> None:
+ reached = (ExportStage.WALKING, ExportStage.COMPRESSING)
+ assert view_model(stages=reached).stage_reached(ExportStage.WALKING) is True
+
+ def test_the_stage_under_way_does_not_read_as_behind(self) -> None:
+ reached = (ExportStage.WALKING, ExportStage.COMPRESSING)
+ assert view_model(stages=reached).stage_reached(ExportStage.COMPRESSING) is False
+
+ def test_a_stage_the_run_never_reached_is_left_off_the_list(self) -> None:
+ assert view_model().stage_visible(ExportStage.WRITING) is False
+
+
+class TestHowTheStageUnderWayReads:
+ """A stage arriving at an end carries a bar; one that does not carries the turning symbol."""
+
+ def test_a_travelling_stage_shows_its_bar(self) -> None:
+ assert view_model(traveling=True).progress_visible is True
+
+ def test_a_travelling_stage_hides_the_turning_symbol(self) -> None:
+ assert view_model(traveling=True).working_visible is False
+
+ def test_a_stage_without_an_end_shows_the_turning_symbol(self) -> None:
+ assert view_model(traveling=False, figure=SIZE).working_visible is True
+
+ def test_a_bar_is_labeled_with_the_share_it_has_covered(self) -> None:
+ assert view_model(progress=HALFWAY).progress_overlay == "50%"
+
+
+class TestStoppingARun:
+ """A stop is offered until it is taken."""
+
+ def test_a_running_export_takes_a_stop(self) -> None:
+ assert view_model(phase=ExportPhase.EXPORTING).cancel_enabled is True
+
+ def test_an_export_already_stopping_takes_no_further_stop(self) -> None:
+ assert view_model(phase=ExportPhase.CANCELLING).cancel_enabled is False
+
+ def test_an_export_being_stopped_still_holds_the_screen(self) -> None:
+ assert view_model(phase=ExportPhase.CANCELLING).is_active is True
diff --git a/tests/unit/sampletones_application/view_model/shared/test_render.py b/tests/unit/sampletones_application/view_model/shared/test_render.py
index f2f37bf01..52ab51cb2 100644
--- a/tests/unit/sampletones_application/view_model/shared/test_render.py
+++ b/tests/unit/sampletones_application/view_model/shared/test_render.py
@@ -72,7 +72,7 @@ def test_a_depth_survives_a_rate_change(self) -> None:
assert settings.spec.sample_rate == 8000
assert settings.depth == AudioDepth.PCM_U8
- def test_the_normalise_choice_stands_through_a_format_change(self) -> None:
+ def test_the_normalize_choice_stands_through_a_format_change(self) -> None:
settings = wave_settings().with_normalize(True).with_format(AudioFormat.MP3)
assert settings.normalize
@@ -154,5 +154,5 @@ def test_a_song_holding_no_rows_starts_no_render(self) -> None:
assert not view.render_enabled
def test_an_outcome_releases_the_application(self) -> None:
- for phase in (RenderPhase.COMPLETED, RenderPhase.CANCELLED, RenderPhase.FAILED):
+ for phase in (RenderPhase.COMPLETED, RenderPhase.CANCELED, RenderPhase.FAILED):
assert not view_model(wave_settings(), phase=phase).is_active
diff --git a/tests/unit/sampletones_application/view_model/shared/test_waveform_data.py b/tests/unit/sampletones_application/view_model/shared/test_waveform_data.py
index 942723b9e..b6ae1186f 100644
--- a/tests/unit/sampletones_application/view_model/shared/test_waveform_data.py
+++ b/tests/unit/sampletones_application/view_model/shared/test_waveform_data.py
@@ -4,19 +4,19 @@
import pytest
from sampletones_application.view_model.shared.waveform_data import WaveformData
-from sampletones_core.constants.enums import GeneratorName
+from sampletones_core.constants.enums import ChannelName
@pytest.fixture
-def approximations() -> Dict[GeneratorName, np.ndarray]:
+def approximations() -> Dict[ChannelName, np.ndarray]:
return {
- GeneratorName.PULSE1: np.array([1.0, 0.0, -1.0, 0.0]),
- GeneratorName.NOISE: np.array([0.5, 0.5, 0.5, 0.5]),
+ ChannelName.PULSE1: np.array([1.0, 0.0, -1.0, 0.0]),
+ ChannelName.NOISE: np.array([0.5, 0.5, 0.5, 0.5]),
}
@pytest.fixture
-def waveform_data(approximations: Dict[GeneratorName, np.ndarray]) -> WaveformData:
+def waveform_data(approximations: Dict[ChannelName, np.ndarray]) -> WaveformData:
return WaveformData(
original_audio=np.zeros(4),
approximation=np.array([1.5, 0.5, -0.5, 0.5]),
@@ -31,32 +31,62 @@ def test_empty_selection_is_silent(self, waveform_data: WaveformData) -> None:
assert np.all(waveform_data.partials([]) == 0.0)
def test_missing_generator_is_silent(self, waveform_data: WaveformData) -> None:
- assert np.all(waveform_data.partials([GeneratorName.TRIANGLE]) == 0.0)
+ assert np.all(waveform_data.partials([ChannelName.TRIANGLE]) == 0.0)
def test_single_generator_returns_its_approximation(
self,
waveform_data: WaveformData,
- approximations: Dict[GeneratorName, np.ndarray],
+ approximations: Dict[ChannelName, np.ndarray],
) -> None:
- result = waveform_data.partials([GeneratorName.PULSE1])
+ result = waveform_data.partials([ChannelName.PULSE1])
- assert np.array_equal(result, approximations[GeneratorName.PULSE1])
+ assert np.array_equal(result, approximations[ChannelName.PULSE1])
def test_selection_sums_the_selected_generators(
self,
waveform_data: WaveformData,
- approximations: Dict[GeneratorName, np.ndarray],
+ approximations: Dict[ChannelName, np.ndarray],
) -> None:
- result = waveform_data.partials([GeneratorName.PULSE1, GeneratorName.NOISE])
+ result = waveform_data.partials([ChannelName.PULSE1, ChannelName.NOISE])
- expected = approximations[GeneratorName.PULSE1] + approximations[GeneratorName.NOISE]
+ expected = approximations[ChannelName.PULSE1] + approximations[ChannelName.NOISE]
assert np.array_equal(result, expected)
def test_unknown_generators_are_skipped_within_a_selection(
self,
waveform_data: WaveformData,
- approximations: Dict[GeneratorName, np.ndarray],
+ approximations: Dict[ChannelName, np.ndarray],
) -> None:
- result = waveform_data.partials([GeneratorName.PULSE1, GeneratorName.TRIANGLE])
+ result = waveform_data.partials([ChannelName.PULSE1, ChannelName.TRIANGLE])
- assert np.array_equal(result, approximations[GeneratorName.PULSE1])
+ assert np.array_equal(result, approximations[ChannelName.PULSE1])
+
+ def test_short_generators_leave_the_tail_in_silence(self) -> None:
+ approximation = np.array([1.0, 0.0, -1.0, 0.0])
+ data = WaveformData(
+ original_audio=approximation,
+ approximation=approximation,
+ approximations={ChannelName.PULSE1: np.array([0.5, 0.5]), ChannelName.NOISE: approximation},
+ coefficient=1.0,
+ frame_length=2,
+ )
+
+ result = data.partials([ChannelName.PULSE1, ChannelName.NOISE])
+
+ expected = np.array([1.5, 0.5, -1.0, 0.0])
+ assert np.array_equal(result, expected)
+
+ def test_a_short_single_generator_reaches_the_approximation_length(self) -> None:
+ approximation = np.array([1.0, 0.0, -1.0, 0.0])
+ data = WaveformData(
+ original_audio=approximation,
+ approximation=approximation,
+ approximations={ChannelName.PULSE1: np.array([0.5, 0.5])},
+ coefficient=1.0,
+ frame_length=2,
+ )
+
+ result = data.partials([ChannelName.PULSE1])
+
+ expected = np.array([0.5, 0.5, 0.0, 0.0])
+ assert np.array_equal(result, expected)
diff --git a/tests/unit/sampletones_assets/mark/test_raster.py b/tests/unit/sampletones_assets/mark/test_raster.py
index 5ae61ddf3..91b5ae74c 100644
--- a/tests/unit/sampletones_assets/mark/test_raster.py
+++ b/tests/unit/sampletones_assets/mark/test_raster.py
@@ -26,20 +26,20 @@ def test_the_image_corner_stays_clear_of_the_rounded_frame(self, mark: Mark) ->
image = MarkRaster(mark).render()
assert image.getpixel(CORNER)[ALPHA] == 0
- def test_the_frame_centre_carries_the_background(self, mark: Mark) -> None:
+ def test_the_frame_center_carries_the_background(self, mark: Mark) -> None:
"""The frame reaches the top edge between its rounded corners, so the ground there is opaque."""
image = MarkRaster(mark).render()
- centre = image.size[0] // 2
- assert image.getpixel((centre, 1))[ALPHA] == 255
+ center = image.size[0] // 2
+ assert image.getpixel((center, 1))[ALPHA] == 255
- def test_the_smooth_half_is_drawn_in_its_own_colour(self, mark: Mark) -> None:
+ def test_the_smooth_half_is_drawn_in_its_own_color(self, mark: Mark) -> None:
image = MarkRaster(mark).render()
scale = mark.render.supersample
start = mark.waves.sine.start
pixel = image.getpixel((round(start.x * scale), round(start.y * scale)))
assert pixel[:CHANNELS] == parse_hex_color(mark.colors.sine)[:CHANNELS]
- def test_the_stepped_half_is_drawn_in_its_own_colour(self, mark: Mark) -> None:
+ def test_the_stepped_half_is_drawn_in_its_own_color(self, mark: Mark) -> None:
image = MarkRaster(mark).render()
scale = mark.render.supersample
corner = mark.waves.square.points[1]
diff --git a/tests/unit/sampletones_assets/mark/test_vector.py b/tests/unit/sampletones_assets/mark/test_vector.py
index 081cb682d..108abb064 100644
--- a/tests/unit/sampletones_assets/mark/test_vector.py
+++ b/tests/unit/sampletones_assets/mark/test_vector.py
@@ -18,7 +18,7 @@ def test_the_shipped_vector_is_what_the_definition_renders(self) -> None:
def test_the_template_is_filled_throughout(self) -> None:
assert PLACEHOLDER_PREFIX not in render_vector(Mark.load())
- def test_every_colour_reaches_the_document(self) -> None:
+ def test_every_color_reaches_the_document(self) -> None:
mark = Mark.load()
document = render_vector(mark)
colors = (
@@ -33,7 +33,7 @@ def test_every_colour_reaches_the_document(self) -> None:
assert color in document
def test_the_document_follows_the_definition(self) -> None:
- """A colour changed in the definition is the colour the vector is drawn with."""
+ """A color changed in the definition is the color the vector is drawn with."""
mark = Mark.load()
recolored = mark.model_copy(update={"colors": mark.colors.model_copy(update={"sine": REPLACEMENT_COLOR})})
document = render_vector(recolored)
diff --git a/tests/unit/sampletones_core/audio/test_io.py b/tests/unit/sampletones_core/audio/test_io.py
new file mode 100644
index 000000000..512a2857a
--- /dev/null
+++ b/tests/unit/sampletones_core/audio/test_io.py
@@ -0,0 +1,85 @@
+from pathlib import Path
+from typing import Final
+
+import numpy as np
+
+from sampletones_core.audio.io import load_audio, load_stems, write_wave
+from sampletones_core.configs import Config
+
+SAMPLE_RATE: Final[int] = Config().library.sample_rate
+QUANTIZATION_LEVELS: Final[int] = Config().general.quantization_levels
+SAMPLES: Final[int] = 64
+
+
+def _write(path: Path, level: float, samples: int = SAMPLES) -> Path:
+ write_wave(path, SAMPLE_RATE, np.full(samples, level, dtype=np.float32))
+ return path
+
+
+def _load_stems(*paths: Path, normalize: bool = True, quantize: bool = False) -> tuple:
+ return load_stems(
+ paths,
+ target_sample_rate=SAMPLE_RATE,
+ normalize=normalize,
+ quantize=quantize,
+ quantization_levels=QUANTIZATION_LEVELS,
+ )
+
+
+class TestLoadStems:
+ """A set of recordings loaded onto one scale and one length."""
+
+ def test_one_recording_reaches_what_loading_it_alone_reaches(self, tmp_path: Path) -> None:
+ """Scaling a lone recording by the peak of its own sum is normalizing it."""
+ path = _write(tmp_path / "alone.wav", 0.4)
+
+ (loaded,) = _load_stems(path)
+
+ np.testing.assert_allclose(
+ loaded,
+ load_audio(path, target_sample_rate=SAMPLE_RATE, normalize=True, quantize=False),
+ )
+
+ def test_the_recordings_keep_the_balance_they_were_captured_in(self, tmp_path: Path) -> None:
+ louder, quieter = _load_stems(
+ _write(tmp_path / "loud.wav", 0.6),
+ _write(tmp_path / "quiet.wav", 0.2),
+ )
+
+ np.testing.assert_allclose(louder, 3.0 * quieter, rtol=1e-6)
+
+ def test_the_set_reaches_the_full_range(self, tmp_path: Path) -> None:
+ recordings = _load_stems(
+ _write(tmp_path / "first.wav", 0.6),
+ _write(tmp_path / "second.wav", 0.2),
+ )
+
+ np.testing.assert_allclose(np.max(np.abs(sum(recordings))), 1.0, rtol=1e-6)
+
+ def test_a_shorter_recording_runs_on_in_silence(self, tmp_path: Path) -> None:
+ longer, shorter = _load_stems(
+ _write(tmp_path / "longer.wav", 0.5),
+ _write(tmp_path / "shorter.wav", 0.5, samples=SAMPLES // 2),
+ )
+
+ assert len(shorter) == len(longer) == SAMPLES
+ np.testing.assert_array_equal(shorter[SAMPLES // 2 :], np.zeros(SAMPLES // 2, dtype=np.float32))
+
+ def test_silence_stays_silent(self, tmp_path: Path) -> None:
+ """A set holding no sound has no peak to scale by, so it comes back as it went in."""
+ recordings = _load_stems(_write(tmp_path / "silent.wav", 0.0))
+
+ np.testing.assert_array_equal(recordings[0], np.zeros(SAMPLES, dtype=np.float32))
+
+ def test_unscaled_loading_keeps_the_captured_levels(self, tmp_path: Path) -> None:
+ louder, quieter = _load_stems(
+ _write(tmp_path / "loud.wav", 0.6),
+ _write(tmp_path / "quiet.wav", 0.2),
+ normalize=False,
+ )
+
+ np.testing.assert_allclose(np.max(np.abs(louder)), 0.6, rtol=1e-6)
+ np.testing.assert_allclose(np.max(np.abs(quieter)), 0.2, rtol=1e-6)
+
+ def test_no_paths_reach_no_recordings(self) -> None:
+ assert _load_stems() == ()
diff --git a/tests/unit/sampletones_core/audio/test_mixing.py b/tests/unit/sampletones_core/audio/test_mixing.py
new file mode 100644
index 000000000..6bb9abb93
--- /dev/null
+++ b/tests/unit/sampletones_core/audio/test_mixing.py
@@ -0,0 +1,134 @@
+from dataclasses import dataclass
+from typing import Any, List, Type, Union
+
+import numpy as np
+import pytest
+
+from sampletones_core.audio.mixing import align, common_length, mix
+from tests.suite.arrays import assert_array_equal
+from tests.suite.base import BaseTestSuite
+from tests.suite.case import BaseRegularTestCase
+from tests.suite.errors import expect_error
+
+
+class TestCommonLength:
+ """The length a set of tracks reaches when they are laid over one another."""
+
+ def test_the_longest_track_sets_the_length(self) -> None:
+ assert common_length([np.zeros(3), np.zeros(7), np.zeros(5)]) == 7
+
+ def test_tracks_of_one_length_keep_it(self) -> None:
+ assert common_length([np.zeros(4), np.zeros(4)]) == 4
+
+ def test_no_tracks_reach_no_length(self) -> None:
+ assert common_length([]) == 0
+
+
+class TestAlign(BaseTestSuite):
+ @dataclass(frozen=True, kw_only=True)
+ class TestCase(BaseRegularTestCase):
+ expected: Union[List[np.ndarray], Type[Exception]]
+ tracks: Any
+ length: int
+
+ test_cases = (
+ TestCase(
+ label="a_shorter_track_runs_on_in_silence",
+ tracks=[np.array([1.0, 2.0])],
+ length=4,
+ expected=[np.array([1.0, 2.0, 0.0, 0.0])],
+ ),
+ TestCase(
+ label="a_longer_track_ends_at_the_length",
+ tracks=[np.array([1.0, 2.0, 3.0])],
+ length=2,
+ expected=[np.array([1.0, 2.0])],
+ ),
+ TestCase(
+ label="a_track_of_the_length_stands_as_it_is",
+ tracks=[np.array([1.0, 2.0])],
+ length=2,
+ expected=[np.array([1.0, 2.0])],
+ ),
+ TestCase(
+ label="every_track_reaches_the_same_length",
+ tracks=[np.array([1.0]), np.array([2.0, 3.0, 4.0])],
+ length=3,
+ expected=[np.array([1.0, 0.0, 0.0]), np.array([2.0, 3.0, 4.0])],
+ ),
+ TestCase(
+ label="no_tracks_align_to_none",
+ tracks=[],
+ length=5,
+ expected=[],
+ ),
+ TestCase(
+ label="a_negative_length_raises",
+ tracks=[np.array([1.0])],
+ length=-1,
+ expected=ValueError,
+ ),
+ TestCase(
+ label="a_two_dimensional_track_raises",
+ tracks=[np.zeros((2, 2))],
+ length=2,
+ expected=ValueError,
+ ),
+ TestCase(
+ label="a_list_is_not_a_track",
+ tracks=[[1.0, 2.0]],
+ length=2,
+ expected=TypeError,
+ ),
+ )
+
+ @pytest.mark.parametrize(
+ "test_case",
+ test_cases,
+ ids=lambda test_case: test_case.label,
+ )
+ def test_align(self, test_case: TestCase) -> None:
+ if expect_error(align, test_case.expected, test_case.tracks, test_case.length):
+ return
+
+ aligned = align(test_case.tracks, test_case.length)
+ assert len(aligned) == len(test_case.expected)
+ for track, expected in zip(aligned, test_case.expected):
+ assert_array_equal(track, expected)
+
+
+class TestMix:
+ """Tracks summed into one waveform, each carrying the level it reaches the mix at."""
+
+ def test_tracks_of_one_length_sum_sample_by_sample(self) -> None:
+ assert_array_equal(
+ mix([np.array([0.25, -0.25]), np.array([0.5, 0.5])]),
+ np.array([0.75, 0.25], dtype=np.float32),
+ )
+
+ def test_a_shorter_track_falls_silent_at_its_end(self) -> None:
+ assert_array_equal(
+ mix([np.array([1.0, 1.0, 1.0]), np.array([0.5])]),
+ np.array([1.5, 1.0, 1.0], dtype=np.float32),
+ )
+
+ def test_the_mix_lasts_as_long_as_the_longest_track(self) -> None:
+ assert len(mix([np.zeros(3), np.zeros(9)])) == 9
+
+ def test_one_track_reaches_the_mix_as_it_stands(self) -> None:
+ assert_array_equal(
+ mix([np.array([0.5, -0.5])]),
+ np.array([0.5, -0.5], dtype=np.float32),
+ )
+
+ def test_no_tracks_mix_to_silence(self) -> None:
+ mixed = mix([])
+ assert mixed.dtype == np.float32
+ assert len(mixed) == 0
+
+ def test_the_mix_is_float32_whatever_the_tracks_carry(self) -> None:
+ assert mix([np.array([1, 2], dtype=np.int32)]).dtype == np.float32
+
+ def test_a_track_that_is_not_an_array_raises(self) -> None:
+ with pytest.raises(TypeError):
+ mix([[1.0, 2.0]])
diff --git a/tests/unit/sampletones_core/audio/test_processing.py b/tests/unit/sampletones_core/audio/test_processing.py
index e83b77995..e10dfe4c2 100644
--- a/tests/unit/sampletones_core/audio/test_processing.py
+++ b/tests/unit/sampletones_core/audio/test_processing.py
@@ -233,7 +233,7 @@ def test_resample(self) -> None:
target_sr=target_sample_rate,
)
- def test_resample_identical(self) -> None:
+ def test_revoice_identical(self) -> None:
array = np.array([2.0, 3.0], dtype=np.float32)
sample_rate = 22050
target_sample_rate = 22050
diff --git a/tests/unit/sampletones_core/compatibility/__init__.py b/tests/unit/sampletones_core/compatibility/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/tests/unit/sampletones_core/compatibility/project/__init__.py b/tests/unit/sampletones_core/compatibility/project/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/tests/unit/sampletones_core/compatibility/project/test_v1_1.py b/tests/unit/sampletones_core/compatibility/project/test_v1_1.py
new file mode 100644
index 000000000..265dccfea
--- /dev/null
+++ b/tests/unit/sampletones_core/compatibility/project/test_v1_1.py
@@ -0,0 +1,72 @@
+from typing import Any, Dict
+
+from sampletones_core.compatibility.fields import (
+ KIND,
+ KIND_SAMPLE,
+ NAME,
+ SAMPLES,
+ VOICE_ID,
+ VOICES,
+)
+from sampletones_core.compatibility.project.v1_1 import update
+
+
+def _pool(extra: Dict[str, Any]) -> Dict[str, Any]:
+ return {"generator": "pulse1", "patterns": {}, **extra}
+
+
+def _pool_with_row(command: Dict[str, Any]) -> Dict[str, Any]:
+ return {"generator": "pulse1", "patterns": {"0": {"rows": [{"command": command}]}}}
+
+
+def _song(command: Dict[str, Any]) -> Dict[str, Any]:
+ return {"song": {"channels": {"pulse1": _pool_with_row(command)}}}
+
+
+def _first_command(data: Dict[str, Any]) -> Dict[str, Any]:
+ command: Dict[str, Any] = data["song"]["channels"]["pulse1"]["patterns"]["0"]["rows"][0]["command"]
+ return command
+
+
+class TestProjectV1_1:
+ def test_gathers_samples_into_voices(self) -> None:
+ data = {SAMPLES: [{"id": "a", "name": "Lead", "reconstruction_id": "r"}]}
+
+ upgraded = update(data)
+
+ assert SAMPLES not in upgraded
+ assert upgraded[VOICES] == [{KIND: KIND_SAMPLE, "id": "a", "name": "Lead", "reconstruction_id": "r"}]
+
+ def test_renames_channel_pool_field(self) -> None:
+ data = {"song": {"channels": {"pulse1": _pool({})}}}
+
+ upgraded = update(data)
+
+ assert upgraded["song"]["channels"]["pulse1"][NAME] == "pulse1"
+ assert "generator" not in upgraded["song"]["channels"]["pulse1"]
+
+ def test_names_the_voice_alone(self) -> None:
+ data = _song({"sample_id": "a", "generator_name": "pulse1"})
+
+ upgraded = update(data)
+
+ assert _first_command(upgraded) == {VOICE_ID: "a"}
+
+ def test_leaves_note_off_commands_untouched(self) -> None:
+ data = _song({})
+
+ assert _first_command(update(data)) == {}
+
+ def test_leaves_the_input_untouched(self) -> None:
+ data = {SAMPLES: [{"id": "a", "name": "Lead"}], **_song({"sample_id": "a", "generator_name": "pulse1"})}
+
+ update(data)
+
+ assert SAMPLES in data
+ assert data["song"]["channels"]["pulse1"]["generator"] == "pulse1"
+ assert _first_command(data) == {"sample_id": "a", "generator_name": "pulse1"}
+
+ def test_document_without_samples_or_a_song_stays_the_same_shape(self) -> None:
+ data = {"format_version": "1.0"}
+
+ assert update(data) == data
diff --git a/tests/unit/sampletones_core/compatibility/reconstruction/__init__.py b/tests/unit/sampletones_core/compatibility/reconstruction/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/tests/unit/sampletones_core/compatibility/reconstruction/test_v2_2.py b/tests/unit/sampletones_core/compatibility/reconstruction/test_v2_2.py
new file mode 100644
index 000000000..192b18410
--- /dev/null
+++ b/tests/unit/sampletones_core/compatibility/reconstruction/test_v2_2.py
@@ -0,0 +1,95 @@
+from typing import Any, Dict
+
+from sampletones_core.compatibility.fields import (
+ AUDIO_FILEPATH,
+ CHANNEL_NAME,
+ CHANNELS,
+ GENERATOR_NAME,
+ INSTRUCTIONS,
+ STEMS_DATA,
+)
+from sampletones_core.compatibility.reconstruction.v2_2 import update
+from sampletones_core.constants.algorithm import DEFAULT_STEMS_CHANNEL_CAP
+
+
+def _stream(extra: Dict[str, Any]) -> Dict[str, Any]:
+ return {GENERATOR_NAME: "pulse1", "instructions": [], **extra}
+
+
+class TestReconstructionV2_2:
+ def test_renames_approximation_entries(self) -> None:
+ data = {"approximations_data": [{GENERATOR_NAME: "pulse1", "approximation": [1.0, 2.0]}]}
+
+ upgraded = update(data)
+
+ assert upgraded["approximations_data"][0][CHANNEL_NAME] == "pulse1"
+ assert GENERATOR_NAME not in upgraded["approximations_data"][0]
+
+ def test_renames_instruction_entries(self) -> None:
+ data = {"instructions_data": [_stream({})]}
+
+ upgraded = update(data)
+
+ assert upgraded["instructions_data"][0][CHANNEL_NAME] == "pulse1"
+ assert GENERATOR_NAME not in upgraded["instructions_data"][0]
+
+ def test_renames_embedded_channel_selection(self) -> None:
+ data = {"config": {"generation": {"generators": ["pulse1", "noise"], "drive": 1.0}}}
+
+ upgraded = update(data)
+
+ assert upgraded["config"]["generation"]["channels"] == ["pulse1", "noise"]
+ assert "generators" not in upgraded["config"]["generation"]
+
+ def test_stamps_the_embedded_config_metadata(self) -> None:
+ data = {"config": {"metadata": {"reconstruction_data_version": "2.1"}}}
+
+ upgraded = update(data)
+
+ assert upgraded["config"]["metadata"]["reconstruction_data_version"] == "2.2"
+
+ def test_leaves_the_input_untouched(self) -> None:
+ data = {
+ "approximations_data": [{GENERATOR_NAME: "pulse1"}],
+ "instructions_data": [_stream({})],
+ }
+
+ update(data)
+
+ assert data["approximations_data"][0][GENERATOR_NAME] == "pulse1"
+ assert data["instructions_data"][0][GENERATOR_NAME] == "pulse1"
+
+ def test_payload_without_known_sections_gains_the_stems_record(self) -> None:
+ data = {"id": "abc"}
+
+ upgraded = update(data)
+
+ assert upgraded["id"] == "abc"
+ assert upgraded[AUDIO_FILEPATH] == []
+ assert upgraded[STEMS_DATA]["config"]["entries"][0]["channels"] == []
+ assert upgraded[STEMS_DATA]["assignments"] == []
+
+ def test_a_single_path_records_as_a_one_tuple(self) -> None:
+ data = {AUDIO_FILEPATH: "/audio/kick.wav"}
+
+ upgraded = update(data)
+
+ assert upgraded[AUDIO_FILEPATH] == ["/audio/kick.wav"]
+
+ def test_a_file_without_stems_data_gains_the_single_entry_record(self) -> None:
+ data = {
+ "config": {"generation": {"channels": ["pulse1", "noise"]}},
+ "instructions_data": [
+ {CHANNEL_NAME: "pulse1", INSTRUCTIONS: ["frame", "frame"]},
+ {CHANNEL_NAME: "noise", INSTRUCTIONS: []},
+ ],
+ }
+
+ upgraded = update(data)
+
+ stems_data = upgraded[STEMS_DATA]
+ assert stems_data["config"]["entries"] == [{"id": 0, CHANNELS: ["pulse1", "noise"]}]
+ assert stems_data["config"]["channel_cap"] == DEFAULT_STEMS_CHANNEL_CAP
+ assert stems_data["assignments"] == [
+ {CHANNEL_NAME: "pulse1", "stem_ids": [0, 0]},
+ ]
diff --git a/tests/unit/sampletones_core/compatibility/test_binary.py b/tests/unit/sampletones_core/compatibility/test_binary.py
new file mode 100644
index 000000000..d77d3f262
--- /dev/null
+++ b/tests/unit/sampletones_core/compatibility/test_binary.py
@@ -0,0 +1,64 @@
+import msgpack
+
+from sampletones_core.compatibility.kind import ObjectKind
+from sampletones_core.compatibility.upgrade import upgrade_binary
+from sampletones_shared.application import (
+ SAMPLETONES_LIBRARY_DATA_VERSION,
+ SAMPLETONES_RECONSTRUCTION_DATA_VERSION,
+)
+
+
+class TestUpgradeBinary:
+ def test_current_reconstruction_version_returns_the_same_bytes(self) -> None:
+ binary = msgpack.packb(
+ {"metadata": {"reconstruction_data_version": SAMPLETONES_RECONSTRUCTION_DATA_VERSION}},
+ use_bin_type=True,
+ )
+
+ assert upgrade_binary(ObjectKind.RECONSTRUCTION, binary) is binary
+
+ def test_current_library_version_returns_the_same_bytes(self) -> None:
+ binary = msgpack.packb(
+ {"metadata": {"library_data_version": SAMPLETONES_LIBRARY_DATA_VERSION}},
+ use_bin_type=True,
+ )
+
+ assert upgrade_binary(ObjectKind.LIBRARY, binary) is binary
+
+ def test_missing_metadata_returns_the_same_bytes(self) -> None:
+ binary = msgpack.packb({"items": []}, use_bin_type=True)
+
+ assert upgrade_binary(ObjectKind.RECONSTRUCTION, binary) is binary
+
+ def test_non_mapping_payload_returns_the_same_bytes(self) -> None:
+ binary = msgpack.packb([1, 2, 3], use_bin_type=True)
+
+ assert upgrade_binary(ObjectKind.RECONSTRUCTION, binary) is binary
+
+ def test_malformed_payload_returns_the_same_bytes(self) -> None:
+ binary = b"\xc1"
+
+ assert upgrade_binary(ObjectKind.RECONSTRUCTION, binary) is binary
+
+ def test_reconstruction_upgrade_renames_channels_and_stamps(self) -> None:
+ binary = msgpack.packb(
+ {
+ "metadata": {"reconstruction_data_version": "2.1"},
+ "approximations_data": [{"generator_name": "pulse1", "approximation": [1.0, 2.0]}],
+ "instructions_data": [],
+ "config": {
+ "metadata": {"reconstruction_data_version": "2.1"},
+ "generation": {"generators": ["pulse1", "noise"]},
+ },
+ },
+ use_bin_type=True,
+ )
+
+ upgraded = upgrade_binary(ObjectKind.RECONSTRUCTION, binary)
+ data = msgpack.unpackb(upgraded, raw=False)
+
+ assert data["approximations_data"][0]["channel_name"] == "pulse1"
+ assert "generator_name" not in data["approximations_data"][0]
+ assert data["config"]["generation"]["channels"] == ["pulse1", "noise"]
+ assert data["config"]["metadata"]["reconstruction_data_version"] == SAMPLETONES_RECONSTRUCTION_DATA_VERSION
+ assert data["metadata"]["reconstruction_data_version"] == SAMPLETONES_RECONSTRUCTION_DATA_VERSION
diff --git a/tests/unit/sampletones_core/compatibility/test_json.py b/tests/unit/sampletones_core/compatibility/test_json.py
new file mode 100644
index 000000000..d739902d8
--- /dev/null
+++ b/tests/unit/sampletones_core/compatibility/test_json.py
@@ -0,0 +1,52 @@
+import json
+
+from sampletones_core.compatibility.kind import ObjectKind
+from sampletones_core.compatibility.upgrade import upgrade_json
+from sampletones_shared.application import SAMPLETONES_PROJECT_DATA_VERSION
+
+
+class TestUpgradeJson:
+ def test_current_version_returns_the_same_bytes(self) -> None:
+ raw = json.dumps({"format_version": SAMPLETONES_PROJECT_DATA_VERSION}).encode("utf-8")
+
+ assert upgrade_json(ObjectKind.PROJECT, raw) is raw
+
+ def test_missing_version_returns_the_same_bytes(self) -> None:
+ raw = json.dumps({"samples": []}).encode("utf-8")
+
+ assert upgrade_json(ObjectKind.PROJECT, raw) is raw
+
+ def test_non_mapping_document_returns_the_same_bytes(self) -> None:
+ raw = b"[1, 2, 3]"
+
+ assert upgrade_json(ObjectKind.PROJECT, raw) is raw
+
+ def test_malformed_document_returns_the_same_bytes(self) -> None:
+ raw = b"{ not valid json"
+
+ assert upgrade_json(ObjectKind.PROJECT, raw) is raw
+
+ def test_project_upgrade_renames_channel_fields_and_stamps(self) -> None:
+ raw = json.dumps(
+ {
+ "format_version": "1.0",
+ "song": {
+ "channels": {
+ "pulse1": {
+ "generator": "pulse1",
+ "patterns": {"0": {"rows": [{"command": {"sample_id": "s", "generator_name": "pulse1"}}]}},
+ }
+ }
+ },
+ }
+ ).encode("utf-8")
+
+ upgraded = upgrade_json(ObjectKind.PROJECT, raw)
+ data = json.loads(upgraded)
+
+ assert data["format_version"] == SAMPLETONES_PROJECT_DATA_VERSION
+ channel = data["song"]["channels"]["pulse1"]
+ assert channel["name"] == "pulse1"
+ assert "generator" not in channel
+ command = channel["patterns"]["0"]["rows"][0]["command"]
+ assert command == {"voice_id": "s"}
diff --git a/tests/unit/sampletones_core/compatibility/test_upgrade.py b/tests/unit/sampletones_core/compatibility/test_upgrade.py
new file mode 100644
index 000000000..f42291889
--- /dev/null
+++ b/tests/unit/sampletones_core/compatibility/test_upgrade.py
@@ -0,0 +1,93 @@
+from typing import Final
+
+import pytest
+
+from sampletones_core.compatibility.kind import ObjectKind
+from sampletones_core.compatibility.update import VersionUpdate
+from sampletones_core.compatibility.upgrade import upgrade
+from sampletones_shared.deployment.version import Version
+from sampletones_shared.types.data import SerializedData
+
+FIRST_MARKER: Final[str] = "first"
+SECOND_MARKER: Final[str] = "second"
+
+
+def _marking_update(base: str, target: str, marker: str) -> VersionUpdate:
+ def apply(data: SerializedData) -> SerializedData:
+ markers = list(data["markers"])
+ markers.append(marker)
+ return {**data, "markers": markers}
+
+ return VersionUpdate(ObjectKind.LIBRARY, Version.model_validate(base), Version.model_validate(target), apply)
+
+
+def _library_data(version: str) -> SerializedData:
+ return {"metadata": {"library_data_version": version}, "markers": []}
+
+
+class TestUpgradeChain:
+ def test_chain_applies_each_update_in_order(self) -> None:
+ updates = (
+ _marking_update("1.0", "1.1", FIRST_MARKER),
+ _marking_update("1.1", "1.2", SECOND_MARKER),
+ )
+
+ upgraded = upgrade(ObjectKind.LIBRARY, "1.0", _library_data("1.0"), updates, "1.2")
+
+ assert upgraded["markers"] == [FIRST_MARKER, SECOND_MARKER]
+ assert upgraded["metadata"]["library_data_version"] == "1.2"
+
+ def test_current_version_returns_the_input_unchanged(self) -> None:
+ updates = (_marking_update("1.0", "1.1", FIRST_MARKER),)
+ data = _library_data("1.1")
+
+ assert upgrade(ObjectKind.LIBRARY, "1.1", data, updates, "1.1") is data
+
+ def test_partial_chain_returns_the_input_unchanged(self) -> None:
+ updates = (_marking_update("1.0", "1.1", FIRST_MARKER),)
+ data = _library_data("1.0")
+
+ assert upgrade(ObjectKind.LIBRARY, "1.0", data, updates, "1.2") is data
+
+ def test_future_version_returns_the_input_unchanged(self) -> None:
+ updates = (_marking_update("1.0", "1.1", FIRST_MARKER),)
+ data = _library_data("1.2")
+
+ assert upgrade(ObjectKind.LIBRARY, "1.2", data, updates, "1.1") is data
+
+ def test_unknown_starting_version_returns_the_input_unchanged(self) -> None:
+ updates = (_marking_update("1.0", "1.1", FIRST_MARKER),)
+ data = _library_data("0.9")
+
+ assert upgrade(ObjectKind.LIBRARY, "0.9", data, updates, "1.1") is data
+
+ def test_two_component_version_matches_three_component_base(self) -> None:
+ updates = (_marking_update("1.1.0", "1.2", FIRST_MARKER),)
+
+ upgraded = upgrade(ObjectKind.LIBRARY, "1.1", _library_data("1.1"), updates, "1.2")
+
+ assert upgraded["markers"] == [FIRST_MARKER]
+
+ def test_project_update_stamps_format_version(self) -> None:
+ updates = (
+ VersionUpdate(
+ ObjectKind.PROJECT,
+ Version.model_validate("1.0"),
+ Version.model_validate("1.1"),
+ lambda data: {**data, "upgraded": True},
+ ),
+ )
+
+ upgraded = upgrade(ObjectKind.PROJECT, "1.0", {"format_version": "1.0"}, updates, "1.1")
+
+ assert upgraded["format_version"] == "1.1"
+ assert upgraded["upgraded"] is True
+
+ def test_duplicate_base_raises(self) -> None:
+ updates = (
+ _marking_update("1.0", "1.1", FIRST_MARKER),
+ _marking_update("1.0", "1.2", SECOND_MARKER),
+ )
+
+ with pytest.raises(ValueError):
+ upgrade(ObjectKind.LIBRARY, "1.0", _library_data("1.0"), updates, "1.2")
diff --git a/tests/unit/sampletones_core/configs/test_display.py b/tests/unit/sampletones_core/configs/test_display.py
index 6d3d06174..baa20bc9f 100644
--- a/tests/unit/sampletones_core/configs/test_display.py
+++ b/tests/unit/sampletones_core/configs/test_display.py
@@ -6,8 +6,8 @@
DISPLAY_HASH_LENGTH,
DISPLAY_SEPARATOR,
disambiguated_display_name,
+ format_channels,
format_frequencies,
- format_generators,
format_nes_frequency,
format_sample_rate,
format_transformation,
@@ -15,7 +15,7 @@
short_hash,
unique_display_names,
)
-from sampletones_core.constants.enums import GeneratorName, SpectrumMethod
+from sampletones_core.constants.enums import ChannelName, SpectrumMethod
class TestFormatSampleRate:
@@ -43,24 +43,24 @@ def test_marks_the_gamma(self) -> None:
assert format_transformation_gamma(0) == "γ0"
-class TestFormatGenerators:
+class TestFormatChannels:
def test_reads_the_generators_in_the_order_they_are_given(self) -> None:
assert (
- format_generators(
+ format_channels(
[
- GeneratorName.PULSE1,
- GeneratorName.TRIANGLE,
- GeneratorName.NOISE,
+ ChannelName.PULSE1,
+ ChannelName.TRIANGLE,
+ ChannelName.NOISE,
],
)
== "Pulse 1, Triangle, Noise"
)
def test_a_lone_generator_reads_as_its_own_name(self) -> None:
- assert format_generators([GeneratorName.PULSE2]) == "Pulse 2"
+ assert format_channels([ChannelName.PULSE2]) == "Pulse 2"
def test_no_generator_reads_as_nothing(self) -> None:
- assert format_generators([]) == ""
+ assert format_channels([]) == ""
class TestFormatFrequencies:
diff --git a/tests/unit/sampletones_core/configs/test_library.py b/tests/unit/sampletones_core/configs/test_library.py
new file mode 100644
index 000000000..cff94d3c3
--- /dev/null
+++ b/tests/unit/sampletones_core/configs/test_library.py
@@ -0,0 +1,39 @@
+from typing import Final
+
+import pytest
+from pydantic import ValidationError
+
+from sampletones_core.configs import Config
+from sampletones_core.configs.library import InstructionsLibraryConfig
+from sampletones_shared.constants.music import (
+ LIMIT_MAX_PITCH,
+ LIMIT_MIN_PITCH,
+ MAX_A4_FREQUENCY,
+ MIN_A4_FREQUENCY,
+)
+from sampletones_shared.music import Tuning
+
+RETUNED_A4_FREQUENCY: Final[float] = 432.0
+
+
+class TestTuning:
+ def test_the_tuning_states_the_configured_reference(self) -> None:
+ library = InstructionsLibraryConfig(a4_frequency=RETUNED_A4_FREQUENCY, a4_pitch=57)
+ assert library.tuning == Tuning(a4_frequency=RETUNED_A4_FREQUENCY, a4_pitch=57)
+
+ def test_the_configuration_reads_its_librarys_tuning(self) -> None:
+ config = Config(library=InstructionsLibraryConfig(a4_frequency=RETUNED_A4_FREQUENCY))
+ assert config.tuning == config.library.tuning
+ assert config.tuning.a4_frequency == RETUNED_A4_FREQUENCY
+
+
+class TestReference:
+ @pytest.mark.parametrize("a4_frequency", [MIN_A4_FREQUENCY, MAX_A4_FREQUENCY])
+ def test_a_reference_frequency_outside_the_tuning_band_raises(self, a4_frequency: float) -> None:
+ with pytest.raises(ValidationError):
+ InstructionsLibraryConfig(a4_frequency=a4_frequency)
+
+ @pytest.mark.parametrize("a4_pitch", [LIMIT_MIN_PITCH - 1, LIMIT_MAX_PITCH + 1])
+ def test_a_reference_pitch_beyond_the_projects_range_raises(self, a4_pitch: int) -> None:
+ with pytest.raises(ValidationError):
+ InstructionsLibraryConfig(a4_pitch=a4_pitch)
diff --git a/tests/unit/sampletones_core/constants/test_enums.py b/tests/unit/sampletones_core/constants/test_enums.py
index 5fb55627f..3d4d08a5a 100644
--- a/tests/unit/sampletones_core/constants/test_enums.py
+++ b/tests/unit/sampletones_core/constants/test_enums.py
@@ -1,15 +1,15 @@
-from sampletones_core.constants.enums import GeneratorName, abbreviate_generator_names
+from sampletones_core.constants.enums import ChannelName, abbreviate_channel_names
-class TestAbbreviateGeneratorNames:
+class TestAbbreviateChannelNames:
def test_single_generator_produces_its_abbreviation(self) -> None:
- assert abbreviate_generator_names([GeneratorName.PULSE1]) == "P"
+ assert abbreviate_channel_names([ChannelName.PULSE1]) == "P"
def test_multiple_generators_concatenates_in_order(self) -> None:
- assert "PTN" == abbreviate_generator_names(
+ assert "PTN" == abbreviate_channel_names(
[
- GeneratorName.PULSE1,
- GeneratorName.TRIANGLE,
- GeneratorName.NOISE,
+ ChannelName.PULSE1,
+ ChannelName.TRIANGLE,
+ ChannelName.NOISE,
]
)
diff --git a/tests/unit/sampletones_core/data/__init__.py b/tests/unit/sampletones_core/data/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/tests/unit/sampletones_core/data/test_model.py b/tests/unit/sampletones_core/data/test_model.py
new file mode 100644
index 000000000..16a619326
--- /dev/null
+++ b/tests/unit/sampletones_core/data/test_model.py
@@ -0,0 +1,58 @@
+from typing import Final
+
+import msgpack
+
+from sampletones_core.instructions import PulseInstruction
+from sampletones_shared.types.data import SerializedData
+
+PITCH: Final[int] = 60
+VOLUME: Final[int] = 15
+
+
+def _without(payload: bytes, *field_names: str) -> bytes:
+ """The payload as a build that had never heard of ``field_names`` would have written it."""
+ data: SerializedData = msgpack.unpackb(payload, raw=False)
+ for field_name in field_names:
+ data.pop(field_name, None)
+
+ return bytes(msgpack.packb(data, use_bin_type=True))
+
+
+class TestFieldsAPayloadLeavesOut:
+ """A file written before a field existed still loads, taking that field's default.
+
+ Serialization keys every field by name, so a payload from an older build simply says nothing
+ about a field added since. What it means to say nothing is what the field defaults to, which
+ is how a stored reconstruction or instruction library keeps loading as the model grows.
+ """
+
+ def test_a_field_the_payload_omits_takes_its_default(self) -> None:
+ instruction = PulseInstruction(on=True, pitch=PITCH, volume=VOLUME, duty_cycle=1, detune=7)
+
+ older = _without(instruction.serialize(), "detune", "coarse_detune")
+ loaded = PulseInstruction.deserialize(older)
+
+ assert loaded.detune == 0
+ assert loaded.coarse_detune == 0
+ assert loaded.timer_offset == 0
+
+ def test_the_fields_a_payload_states_come_back_as_written(self) -> None:
+ instruction = PulseInstruction(
+ on=True,
+ pitch=PITCH,
+ volume=VOLUME,
+ duty_cycle=1,
+ detune=-3,
+ coarse_detune=2,
+ )
+
+ assert PulseInstruction.deserialize(instruction.serialize()) == instruction
+
+ def test_the_rest_of_the_payload_survives_the_omission(self) -> None:
+ instruction = PulseInstruction(on=True, pitch=PITCH, volume=VOLUME, duty_cycle=1)
+
+ loaded = PulseInstruction.deserialize(_without(instruction.serialize(), "detune"))
+
+ assert loaded.pitch == PITCH
+ assert loaded.volume == VOLUME
+ assert loaded.duty_cycle == 1
diff --git a/tests/unit/sampletones_core/exporters/implementation/test_noise.py b/tests/unit/sampletones_core/exporters/implementation/test_noise.py
index eb1c426ec..99f58d47d 100644
--- a/tests/unit/sampletones_core/exporters/implementation/test_noise.py
+++ b/tests/unit/sampletones_core/exporters/implementation/test_noise.py
@@ -81,13 +81,13 @@ 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)], 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
+class TestNoiseExporterReadEnvelopes:
+ def test_it_reads_the_dimensions_its_generator_offers(self) -> None:
+ envelopes = NoiseExporter.read_envelopes([_noise()], 60)
+ assert FeatureKey.VOLUME in envelopes
+ assert FeatureKey.ARPEGGIO in envelopes
+ assert FeatureKey.DUTY_CYCLE in envelopes
+ assert FeatureKey.INITIAL_PITCH not in envelopes
def test_arpeggio_is_relative_to_the_given_reference_modulo_num_periods(
self,
@@ -96,27 +96,11 @@ def test_arpeggio_is_relative_to_the_given_reference_modulo_num_periods(
_noise(period=2, volume=10),
_noise(period=5, volume=8),
]
- feature_map = NoiseExporter.get_feature_map(instructions, 4)
- arpeggio = feature_map[FeatureKey.ARPEGGIO]
+ envelopes = NoiseExporter.read_envelopes(instructions, 4)
+ arpeggio = envelopes[FeatureKey.ARPEGGIO]
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()], 0)
- assert feature_map[FeatureKey.VOLUME].dtype == np.int8
-
- def test_arpeggio_dtype_is_int8(self) -> None:
- 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()], 0)
- assert feature_map[FeatureKey.DUTY_CYCLE].dtype == np.int8
-
class TestNoiseExporterReconstruction:
def test_features_dictionary_to_instruction_round_trip(self) -> None:
diff --git a/tests/unit/sampletones_core/exporters/implementation/test_pulse.py b/tests/unit/sampletones_core/exporters/implementation/test_pulse.py
index 0b4e842ee..60031f127 100644
--- a/tests/unit/sampletones_core/exporters/implementation/test_pulse.py
+++ b/tests/unit/sampletones_core/exporters/implementation/test_pulse.py
@@ -66,37 +66,24 @@ 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
+class TestPulseExporterReadEnvelopes:
+ def test_it_reads_the_dimensions_its_generator_offers(self) -> None:
+ envelopes = PulseExporter.read_envelopes([_pulse()], 60)
+ assert FeatureKey.VOLUME in envelopes
+ assert FeatureKey.ARPEGGIO in envelopes
+ assert FeatureKey.DUTY_CYCLE in envelopes
+ assert FeatureKey.INITIAL_PITCH not in envelopes
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]
+ envelopes = PulseExporter.read_envelopes(instructions, 60)
+ arpeggio = envelopes[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
+ def test_the_arpeggio_is_measured_from_the_reference_it_is_given(self) -> None:
+ envelopes = PulseExporter.read_envelopes([_pulse(pitch=60)], 55)
+ assert envelopes[FeatureKey.ARPEGGIO][0] == 5
class TestPulseExporterReconstruction:
diff --git a/tests/unit/sampletones_core/exporters/implementation/test_release.py b/tests/unit/sampletones_core/exporters/implementation/test_release.py
new file mode 100644
index 000000000..6196910fc
--- /dev/null
+++ b/tests/unit/sampletones_core/exporters/implementation/test_release.py
@@ -0,0 +1,123 @@
+from dataclasses import dataclass
+from typing import Callable, Final, List, Sequence, Tuple
+
+import pytest
+
+from sampletones_core.constants.general import MAX_VOLUME, MIN_PITCH, SILENT_VOLUME
+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.instructions.implementation.noise import NoiseInstruction
+from sampletones_core.instructions.implementation.pulse import PulseInstruction
+from sampletones_core.instructions.implementation.triangle import TriangleInstruction
+from tests.suite.base import BaseTestSuite
+from tests.suite.case import BaseAutolabelTestCase
+
+VOLUMES: Final[int] = 2
+
+SOUNDING: Final[int] = 12
+LOUDER: Final[int] = 15
+
+
+def pulse_volumes(levels: Sequence[int]) -> List[int]:
+ instructions = [
+ PulseInstruction(on=level > SILENT_VOLUME, pitch=MIN_PITCH, volume=level, duty_cycle=0) for level in levels
+ ]
+ return PulseExporter.extract_data(instructions)[VOLUMES]
+
+
+def triangle_volumes(levels: Sequence[int]) -> List[int]:
+ instructions = [TriangleInstruction(on=level > SILENT_VOLUME, pitch=MIN_PITCH) for level in levels]
+ return TriangleExporter.extract_data(instructions)[VOLUMES]
+
+
+def noise_volumes(levels: Sequence[int]) -> List[int]:
+ instructions = [NoiseInstruction(on=level > SILENT_VOLUME, period=0, volume=level, short=False) for level in levels]
+ return NoiseExporter.extract_data(instructions)[VOLUMES]
+
+
+class TestTheReleaseEveryGeneratorWrites(BaseTestSuite):
+ """A channel that stops sounding writes the silence that stops it.
+
+ A FamiTracker sequence halts on its last item and holds that value for as long as the note
+ sounds, so a volume envelope whose frames end audible carries one silent item past them to
+ release the note. Every generator writes that item, which is what a reader edits in the
+ instruments panel and what an export keeps within the items the file holds.
+ """
+
+ @dataclass(frozen=True, kw_only=True)
+ class Writer(BaseAutolabelTestCase):
+ """One channel's exporter, and the level a sounding frame reaches it at.
+
+ The triangle plays at one level, so a frame there is audible or silent rather than graded,
+ and a case states its levels once for every channel to read what it can of.
+ """
+
+ expected: str
+ volumes: Callable[[Sequence[int]], List[int]]
+ plays_at_one_level: bool
+
+ @property
+ def label(self) -> str:
+ return self.expected
+
+ def written(self, level: int) -> int:
+ """The value this channel writes for a frame stated at ``level``."""
+ if level == SILENT_VOLUME:
+ return SILENT_VOLUME
+
+ return MAX_VOLUME if self.plays_at_one_level else level
+
+ @dataclass(frozen=True, kw_only=True)
+ class TestCase(BaseAutolabelTestCase):
+ expected: Tuple[int, ...]
+ levels: Tuple[int, ...]
+ ending: str
+
+ @property
+ def label(self) -> str:
+ return self.ending
+
+ writers = (
+ Writer(expected="pulse", volumes=pulse_volumes, plays_at_one_level=False),
+ Writer(expected="triangle", volumes=triangle_volumes, plays_at_one_level=True),
+ Writer(expected="noise", volumes=noise_volumes, plays_at_one_level=False),
+ )
+
+ test_cases = (
+ TestCase(
+ ending="ends_sounding",
+ levels=(SOUNDING, SOUNDING),
+ expected=(SOUNDING, SOUNDING, SILENT_VOLUME),
+ ),
+ TestCase(
+ ending="ends_silent",
+ levels=(SOUNDING, SILENT_VOLUME),
+ expected=(SOUNDING, SILENT_VOLUME),
+ ),
+ TestCase(
+ ending="one_sounding_frame",
+ levels=(SOUNDING,),
+ expected=(SOUNDING, SILENT_VOLUME),
+ ),
+ TestCase(
+ ending="nothing_written",
+ levels=(),
+ expected=(),
+ ),
+ )
+
+ @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label)
+ @pytest.mark.parametrize("writer", writers, ids=lambda writer: writer.label)
+ def test_the_volume_written_ends_where_the_channel_stops_sounding(
+ self,
+ writer: Writer,
+ test_case: TestCase,
+ ) -> None:
+ expected = tuple(writer.written(level) for level in test_case.expected)
+ assert tuple(writer.volumes(test_case.levels)) == expected
+
+ @pytest.mark.parametrize("writer", writers, ids=lambda writer: writer.label)
+ def test_a_louder_ending_releases_the_same_way(self, writer: Writer) -> None:
+ """The release follows from the channel going quiet, whatever level it went quiet from."""
+ assert writer.volumes((SOUNDING, LOUDER))[-1] == SILENT_VOLUME
diff --git a/tests/unit/sampletones_core/exporters/implementation/test_triangle.py b/tests/unit/sampletones_core/exporters/implementation/test_triangle.py
index 517cd2913..db2f25397 100644
--- a/tests/unit/sampletones_core/exporters/implementation/test_triangle.py
+++ b/tests/unit/sampletones_core/exporters/implementation/test_triangle.py
@@ -65,32 +65,23 @@ 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)], 60)
- assert FeatureKey.INITIAL_PITCH in feature_map
- assert FeatureKey.VOLUME in feature_map
- assert FeatureKey.ARPEGGIO in feature_map
+class TestTriangleExporterReadEnvelopes:
+ def test_it_reads_the_dimensions_its_generator_offers(self) -> None:
+ envelopes = TriangleExporter.read_envelopes([_tri()], 60)
+ assert FeatureKey.VOLUME in envelopes
+ assert FeatureKey.ARPEGGIO in envelopes
+ assert FeatureKey.INITIAL_PITCH not in envelopes
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, 60)
- arpeggio = feature_map[FeatureKey.ARPEGGIO]
+ envelopes = TriangleExporter.read_envelopes(instructions, 60)
+ arpeggio = envelopes[FeatureKey.ARPEGGIO]
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()], 60)
- assert feature_map[FeatureKey.VOLUME].dtype == np.int8
-
- def test_arpeggio_dtype_is_int8(self) -> None:
- feature_map = TriangleExporter.get_feature_map([_tri()], 60)
- assert feature_map[FeatureKey.ARPEGGIO].dtype == np.int8
+ def test_the_arpeggio_is_measured_from_the_reference_it_is_given(self) -> None:
+ envelopes = TriangleExporter.read_envelopes([_tri(pitch=60)], 55)
+ assert envelopes[FeatureKey.ARPEGGIO][0] == 5
class TestTriangleExporterReconstruction:
diff --git a/tests/unit/sampletones_core/exporters/test_bends.py b/tests/unit/sampletones_core/exporters/test_bends.py
new file mode 100644
index 000000000..f3c72f294
--- /dev/null
+++ b/tests/unit/sampletones_core/exporters/test_bends.py
@@ -0,0 +1,127 @@
+from typing import Final, List, Tuple
+
+import pytest
+
+from sampletones_core.constants.enums import ChannelName, FeatureKey
+from sampletones_core.exporters import PulseExporter, TriangleExporter
+from sampletones_core.features import resting_held_features
+from sampletones_core.features.envelope import Envelope
+from sampletones_core.instructions import PulseInstruction, TriangleInstruction
+
+REFERENCE: Final[int] = 60
+VOLUME: Final[int] = 12
+BENDS: Final[Tuple[int, ...]] = (0, 3, -5, 12)
+COARSE_BENDS: Final[Tuple[int, ...]] = (0, 1, -2, 0)
+
+
+def _pulses() -> List[PulseInstruction]:
+ return [
+ PulseInstruction(
+ on=True,
+ pitch=REFERENCE,
+ volume=VOLUME,
+ duty_cycle=1,
+ detune=detune,
+ coarse_detune=coarse_detune,
+ )
+ for detune, coarse_detune in zip(BENDS, COARSE_BENDS)
+ ]
+
+
+class TestReadingBendsOutOfAStream:
+ def test_both_dimensions_come_back_frame_by_frame(self) -> None:
+ written = PulseExporter.read_envelopes(_pulses(), REFERENCE)
+
+ assert written[FeatureKey.PITCH] == BENDS
+ assert written[FeatureKey.HI_PITCH] == COARSE_BENDS
+
+ def test_a_rest_carries_the_bend_the_last_sounding_frame_stated(self) -> None:
+ instructions = [
+ PulseInstruction(on=True, pitch=REFERENCE, volume=VOLUME, duty_cycle=0, detune=9),
+ PulseInstruction.null_instruction(),
+ PulseInstruction(on=True, pitch=REFERENCE, volume=VOLUME, duty_cycle=0, detune=-4),
+ ]
+
+ written = PulseExporter.read_envelopes(instructions, REFERENCE)
+
+ assert written[FeatureKey.PITCH] == (9, 9, -4)
+
+ def test_a_rest_before_the_first_sounding_frame_takes_its_bend(self) -> None:
+ instructions = [
+ PulseInstruction.null_instruction(),
+ PulseInstruction(on=True, pitch=REFERENCE, volume=VOLUME, duty_cycle=0, detune=6),
+ ]
+
+ written = PulseExporter.read_envelopes(instructions, REFERENCE)
+
+ assert written[FeatureKey.PITCH] == (6, 6)
+
+ def test_a_channel_that_never_sounds_carries_no_bend(self) -> None:
+ written = PulseExporter.read_envelopes([PulseInstruction.null_instruction()] * 3, REFERENCE)
+
+ assert written[FeatureKey.PITCH] == (0, 0, 0)
+ assert written[FeatureKey.HI_PITCH] == (0, 0, 0)
+
+
+class TestRoundTrip:
+ def test_a_bent_stream_survives_the_trip_through_its_envelopes(self) -> None:
+ instructions = _pulses()
+
+ features = PulseExporter.to_features(instructions, REFERENCE, ())
+
+ assert list(PulseExporter.from_features(features))[: len(instructions)] == instructions
+
+ def test_the_triangle_carries_its_bend_the_same_way(self) -> None:
+ instructions = [TriangleInstruction(on=True, pitch=REFERENCE, detune=detune) for detune in BENDS]
+
+ features = TriangleExporter.to_features(instructions, REFERENCE, ())
+
+ assert list(TriangleExporter.from_features(features))[: len(instructions)] == instructions
+
+ def test_a_dimension_left_to_the_channel_sounds_the_note_itself(self) -> None:
+ features = PulseExporter.to_features(
+ _pulses(),
+ REFERENCE,
+ (FeatureKey.PITCH, FeatureKey.HI_PITCH),
+ )
+
+ assert features.envelope(FeatureKey.PITCH).items == ()
+ assert all(not instruction.bent for instruction in PulseExporter.from_features(features))
+
+
+class TestChannelsThatOfferTheBend:
+ @pytest.mark.parametrize(
+ "channel_name",
+ (ChannelName.PULSE1, ChannelName.PULSE2, ChannelName.TRIANGLE),
+ ids=lambda channel_name: str(channel_name),
+ )
+ def test_a_tonal_channel_records_both_bend_dimensions(self, channel_name: ChannelName) -> None:
+ held = resting_held_features(channel_name)
+
+ assert FeatureKey.PITCH in held
+ assert FeatureKey.HI_PITCH in held
+
+ def test_the_noise_channel_records_neither(self) -> None:
+ held = resting_held_features(ChannelName.NOISE)
+
+ assert FeatureKey.PITCH not in held
+ assert FeatureKey.HI_PITCH not in held
+
+
+class TestBendEnvelopesOnAnInstrument:
+ def test_a_bend_envelope_reaches_the_frames_an_instrument_plays(self) -> None:
+ from sampletones_core.project.voices.envelopes import InstrumentEnvelopes
+ from sampletones_core.project.voices.instrument import Instrument
+
+ instrument = Instrument(
+ name="bent",
+ envelopes=InstrumentEnvelopes(
+ volume=Envelope(items=(VOLUME, VOLUME)),
+ pitch=Envelope(items=(4, -4)),
+ ),
+ initial_pitch=REFERENCE,
+ )
+
+ frames = instrument.instructions(ChannelName.PULSE1)
+
+ assert [frame.detune for frame in frames] == [4, -4]
diff --git a/tests/unit/sampletones_core/exporters/test_exporter.py b/tests/unit/sampletones_core/exporters/test_exporter.py
index 4b07b3955..77523414a 100644
--- a/tests/unit/sampletones_core/exporters/test_exporter.py
+++ b/tests/unit/sampletones_core/exporters/test_exporter.py
@@ -4,7 +4,7 @@
import numpy as np
import pytest
-from sampletones_core.constants.enums import FeatureKey
+from sampletones_core.constants.enums import FeatureKey, GeneratorName
from sampletones_core.constants.general import MAX_VOLUME
from sampletones_core.exporters import (
ExporterTypeUnion,
@@ -13,6 +13,12 @@
PulseExporter,
TriangleExporter,
)
+from sampletones_core.features import (
+ CHANNEL_FEATURE_DEFAULTS,
+ FEATURE_DIMENSION_ORDER,
+ supports,
+)
+from sampletones_core.features.envelope import Envelope
from sampletones_core.instructions import (
InstructionUnion,
NoiseInstruction,
@@ -29,6 +35,7 @@
PERIOD_STEP: Final[int] = 3
PULSE_VOLUME: Final[int] = 8
NOISE_VOLUME: Final[int] = 10
+UNREAD_VALUE: Final[int] = 3
def _read_pitch(instruction: Any) -> int:
@@ -64,11 +71,11 @@ def _features(
) -> Features:
return Features(
initial_pitch=initial_pitch,
- volume=np.array(volume, dtype=np.int8),
- arpeggio=np.array(arpeggio, dtype=np.int8),
+ volume=Envelope(items=tuple(volume)),
+ arpeggio=Envelope(items=tuple(arpeggio)),
pitch=None,
hi_pitch=None,
- duty_cycle=None if duty_cycle is None else np.array(duty_cycle, dtype=np.int8),
+ duty_cycle=None if duty_cycle is None else Envelope(items=tuple(duty_cycle)),
)
@@ -143,8 +150,10 @@ def _export(test_case: TestCase, instructions: Sequence[InstructionUnion]) -> Fe
@classmethod
def _edited(cls, test_case: TestCase) -> List[InstructionUnion]:
- features = cls._export(test_case, test_case.instructions)
- features[FeatureKey.ARPEGGIO] = test_case.arpeggio
+ features = cls._export(test_case, test_case.instructions).with_envelope(
+ FeatureKey.ARPEGGIO,
+ Envelope(items=tuple(int(offset) for offset in test_case.arpeggio)),
+ )
return test_case.exporter.from_features(features)
@pytest.mark.parametrize(
@@ -164,7 +173,7 @@ 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]
+ assert list(features.arpeggio.items) == [0]
@pytest.mark.parametrize(
"test_case",
@@ -200,7 +209,7 @@ def test_re_export_keeps_the_stored_reference(self, test_case: TestCase) -> None
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()
+ assert list(features.arpeggio.items) == test_case.arpeggio.tolist()
@pytest.mark.parametrize(
"test_case",
@@ -210,11 +219,13 @@ def test_re_export_reads_the_edited_arpeggio_back(self, test_case: TestCase) ->
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``
+ This is the reported behavior: 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)
+ features = self._export(test_case, self._edited(test_case)).with_envelope(
+ FeatureKey.ARPEGGIO,
+ Envelope(items=(0,) * len(test_case.arpeggio)),
+ )
cleared = test_case.exporter.from_features(features)
@@ -239,11 +250,11 @@ class TestCase(BaseRegularTestCase):
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),
+ volume=Envelope(items=(PULSE_VOLUME, PULSE_VOLUME, 0)),
+ arpeggio=Envelope(items=()),
pitch=None,
hi_pitch=None,
- duty_cycle=np.array([0], dtype=np.int8),
+ duty_cycle=Envelope(items=(0,)),
),
read_pitch=_read_pitch,
expected=REFERENCE_PITCH,
@@ -253,8 +264,8 @@ class TestCase(BaseRegularTestCase):
exporter=TriangleExporter,
features=Features(
initial_pitch=REFERENCE_PITCH,
- volume=np.array([15, 15, 0], dtype=np.int8),
- arpeggio=np.array([], dtype=np.int8),
+ volume=Envelope(items=(15, 15, 0)),
+ arpeggio=Envelope(items=()),
pitch=None,
hi_pitch=None,
duty_cycle=None,
@@ -267,11 +278,11 @@ class TestCase(BaseRegularTestCase):
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),
+ volume=Envelope(items=(NOISE_VOLUME, NOISE_VOLUME, 0)),
+ arpeggio=Envelope(items=()),
pitch=None,
hi_pitch=None,
- duty_cycle=np.array([0], dtype=np.int8),
+ duty_cycle=Envelope(items=(0,)),
),
read_pitch=_read_period,
expected=REFERENCE_PERIOD,
@@ -287,7 +298,7 @@ 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)
+ assert pitches == [test_case.expected] * len(test_case.features.volume.items)
@pytest.mark.parametrize(
"test_case",
@@ -408,7 +419,7 @@ def test_a_held_dimension_comes_back_empty(self) -> None:
features.held_features,
)
- assert exported.arpeggio.size == 0
+ assert not exported.arpeggio.written
assert exported.held_features == (FeatureKey.ARPEGGIO,)
def test_a_written_dimension_comes_back_with_its_items(self) -> None:
@@ -426,9 +437,9 @@ def test_a_written_dimension_comes_back_with_its_items(self) -> None:
features.held_features,
)
- assert exported.volume.tolist() == [PULSE_VOLUME, PULSE_VOLUME, 0]
+ assert list(exported.volume.items) == [PULSE_VOLUME, PULSE_VOLUME, 0]
assert exported.duty_cycle is not None
- assert exported.duty_cycle.tolist() == [1]
+ assert list(exported.duty_cycle.items) == [1]
def test_an_instrument_holding_every_dimension_describes_no_frame(self) -> None:
features = _features(
@@ -452,15 +463,22 @@ class TestSingleFrameReading(BaseTestSuite):
@dataclass(frozen=True, kw_only=True)
class TestCase(BaseRegularTestCase):
exporter: ExporterTypeUnion
+ kind: GeneratorName
instruction: InstructionUnion
silent: InstructionUnion
reference: int
expected: Dict[FeatureKey, int]
+ @property
+ def unread(self) -> Tuple[FeatureKey, ...]:
+ """The dimensions this channel's generator reads nothing from."""
+ return tuple(feature_key for feature_key in FEATURE_DIMENSION_ORDER if not supports(self.kind, feature_key))
+
test_cases = (
TestCase(
label="pulse",
exporter=PulseExporter,
+ kind=GeneratorName.PULSE,
instruction=PulseInstruction(
on=True,
pitch=REFERENCE_PITCH + OCTAVE,
@@ -472,23 +490,29 @@ class TestCase(BaseRegularTestCase):
expected={
FeatureKey.VOLUME: PULSE_VOLUME,
FeatureKey.ARPEGGIO: OCTAVE,
+ FeatureKey.PITCH: CHANNEL_FEATURE_DEFAULTS[FeatureKey.PITCH],
+ FeatureKey.HI_PITCH: CHANNEL_FEATURE_DEFAULTS[FeatureKey.HI_PITCH],
FeatureKey.DUTY_CYCLE: 1,
},
),
TestCase(
label="triangle",
exporter=TriangleExporter,
+ kind=GeneratorName.TRIANGLE,
instruction=TriangleInstruction(on=True, pitch=REFERENCE_PITCH - OCTAVE),
silent=TriangleInstruction.null_instruction(),
reference=REFERENCE_PITCH,
expected={
FeatureKey.VOLUME: MAX_VOLUME,
FeatureKey.ARPEGGIO: -OCTAVE,
+ FeatureKey.PITCH: CHANNEL_FEATURE_DEFAULTS[FeatureKey.PITCH],
+ FeatureKey.HI_PITCH: CHANNEL_FEATURE_DEFAULTS[FeatureKey.HI_PITCH],
},
),
TestCase(
label="noise",
exporter=NoiseExporter,
+ kind=GeneratorName.NOISE,
instruction=NoiseInstruction(
on=True,
period=REFERENCE_PERIOD + PERIOD_STEP,
@@ -538,12 +562,13 @@ def test_the_values_a_frame_states_sound_it_back(self, test_case: TestCase) -> N
@pytest.mark.parametrize(
"test_case",
- test_cases,
+ tuple(test_case for test_case in test_cases if test_case.unread),
ids=lambda test_case: test_case.label,
)
def test_a_dimension_the_channel_reads_nothing_from_is_passed_over(self, test_case: TestCase) -> None:
"""One set of channel values serves every channel, so each takes the dimensions it reads."""
values = dict(test_case.expected)
- values[FeatureKey.HI_PITCH] = 3
+ for feature_key in test_case.unread:
+ values[feature_key] = UNREAD_VALUE
assert test_case.exporter.instruction_from_values(values, test_case.reference) == test_case.instruction
diff --git a/tests/unit/sampletones_core/exporters/test_feature.py b/tests/unit/sampletones_core/exporters/test_feature.py
index 3c4372c33..c12bb17cf 100644
--- a/tests/unit/sampletones_core/exporters/test_feature.py
+++ b/tests/unit/sampletones_core/exporters/test_feature.py
@@ -1,17 +1,16 @@
from typing import Optional
-import numpy as np
-
-from sampletones_core.constants.enums import FeatureKey
-from sampletones_core.exporters import Features
+from sampletones_core.constants.enums import ChannelName, FeatureKey
+from sampletones_core.exporters import Features, playing_channels
+from sampletones_core.features.envelope import Envelope
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)
+ duty_cycle = None if duty_cycle_frames is None else Envelope(items=(0,) * duty_cycle_frames)
return Features(
initial_pitch=60,
- volume=np.full(frames, 15, dtype=int),
- arpeggio=np.zeros(frames, dtype=int),
+ volume=Envelope(items=(15,) * frames),
+ arpeggio=Envelope(items=(0,) * frames),
pitch=None,
hi_pitch=None,
duty_cycle=duty_cycle,
@@ -36,8 +35,7 @@ def test_an_instrument_writing_every_dimension_leaves_none(self) -> None:
assert build_features(8, duty_cycle_frames=8).held_features == ()
def test_an_empty_envelope_marks_a_dimension_the_channel_governs(self) -> None:
- features = build_features(8, duty_cycle_frames=8)
- features[FeatureKey.ARPEGGIO] = np.array([], dtype=np.int8)
+ features = build_features(8, duty_cycle_frames=8).with_envelope(FeatureKey.ARPEGGIO, Envelope(items=()))
assert features.held_features == (FeatureKey.ARPEGGIO,)
def test_a_dimension_the_channel_lacks_stays_out_of_the_listing(self) -> None:
@@ -45,15 +43,39 @@ def test_a_dimension_the_channel_lacks_stays_out_of_the_listing(self) -> None:
assert build_features(8).held_features == ()
def test_leaving_a_dimension_to_the_channel_empties_its_envelope(self) -> None:
- features = build_features(8, duty_cycle_frames=8)
- features.leave_to_channel((FeatureKey.VOLUME, FeatureKey.DUTY_CYCLE))
- assert features.volume.size == 0
- assert features.duty_cycle is not None and features.duty_cycle.size == 0
+ features = build_features(8, duty_cycle_frames=8).leave_to_channel((FeatureKey.VOLUME, FeatureKey.DUTY_CYCLE))
+ assert not features.volume.written
+ assert features.duty_cycle is not None and not features.duty_cycle.written
assert features.held_features == (FeatureKey.VOLUME, FeatureKey.DUTY_CYCLE)
def test_leaving_a_dimension_the_channel_lacks_keeps_it_absent(self) -> None:
"""A record naming a duty cycle on the triangle channel leaves the channel's shape intact."""
- features = build_features(8)
- features.leave_to_channel((FeatureKey.VOLUME, FeatureKey.DUTY_CYCLE))
+ features = build_features(8).leave_to_channel((FeatureKey.VOLUME, FeatureKey.DUTY_CYCLE))
assert features.duty_cycle is None
assert features.held_features == (FeatureKey.VOLUME,)
+
+
+class TestWhichChannelsSound:
+ """Describing a frame is what puts a channel in play, and every reader asks the same way."""
+
+ def test_a_channel_describing_frames_sounds(self) -> None:
+ channels = {ChannelName.PULSE1: build_features(8)}
+
+ assert playing_channels(channels) == frozenset({ChannelName.PULSE1})
+
+ def test_a_channel_describing_nothing_stands_by(self) -> None:
+ channels = {ChannelName.TRIANGLE: build_features(0)}
+
+ assert playing_channels(channels) == frozenset()
+
+ def test_the_channels_that_sound_are_told_from_the_ones_that_stand_by(self) -> None:
+ channels = {
+ ChannelName.PULSE1: build_features(8),
+ ChannelName.PULSE2: build_features(0),
+ ChannelName.NOISE: build_features(4),
+ }
+
+ assert playing_channels(channels) == frozenset({ChannelName.PULSE1, ChannelName.NOISE})
+
+ def test_nothing_offered_sounds_nowhere(self) -> None:
+ assert playing_channels({}) == frozenset()
diff --git a/tests/unit/sampletones_core/exporters/test_lengths.py b/tests/unit/sampletones_core/exporters/test_lengths.py
deleted file mode 100644
index 785563b0d..000000000
--- a/tests/unit/sampletones_core/exporters/test_lengths.py
+++ /dev/null
@@ -1,111 +0,0 @@
-import logging
-from typing import Dict, Final, Tuple
-
-import pytest
-
-from sampletones_core.exporters.lengths import equalize_lengths, limit_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 TestLimitLengths:
- def test_every_dimension_keeps_its_own_length(self) -> None:
- limited = limit_lengths({VOLUME: (15, 12, 9, 0), ARPEGGIO: (0, 2, 4)}, limit=ITEM_LIMIT)
- assert limited[VOLUME] == (15, 12, 9, 0)
- assert limited[ARPEGGIO] == (0, 2, 4)
-
- def test_empty_dimensions_stay_empty(self) -> None:
- limited = limit_lengths({VOLUME: (15, 12, 0), ARPEGGIO: ()}, limit=ITEM_LIMIT)
- assert limited[ARPEGGIO] == ()
-
- def test_an_over_long_envelope_keeps_its_opening_items(self) -> None:
- limited = limit_lengths(volume_and_arpeggio(ITEM_LIMIT + 48), limit=ITEM_LIMIT)
- assert limited[VOLUME] == items_of(ITEM_LIMIT)
- assert len(limited[ARPEGGIO]) == ITEM_LIMIT
-
- def test_an_over_long_envelope_is_reported(self, caplog: pytest.LogCaptureFixture) -> None:
- with caplog.at_level(logging.WARNING):
- limit_lengths(volume_and_arpeggio(ITEM_LIMIT + 1), 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):
- limit_lengths(volume_and_arpeggio(ITEM_LIMIT), limit=ITEM_LIMIT)
-
- assert caplog.text == ""
-
-
-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
index a3ffb93ac..61ffb649b 100644
--- a/tests/unit/sampletones_core/exporters/test_naming.py
+++ b/tests/unit/sampletones_core/exporters/test_naming.py
@@ -3,7 +3,7 @@
import pytest
-from sampletones_core.constants.enums import GeneratorName
+from sampletones_core.constants.enums import ChannelName
from sampletones_core.exporters.naming import instrument_slice_name
from tests.suite.base import BaseTestSuite
from tests.suite.case import BaseRegularTestCase
@@ -14,29 +14,29 @@
class TestInstrumentSliceName(BaseTestSuite):
@dataclass(frozen=True, kw_only=True)
class NameCase(BaseRegularTestCase):
- generator: GeneratorName
+ channel: ChannelName
expected: str
test_cases = (
NameCase(
- generator=GeneratorName.PULSE1,
+ channel=ChannelName.PULSE1,
expected="Kick (pulse1)",
- label=GeneratorName.PULSE1.value,
+ label=ChannelName.PULSE1.value,
),
NameCase(
- generator=GeneratorName.PULSE2,
+ channel=ChannelName.PULSE2,
expected="Kick (pulse2)",
- label=GeneratorName.PULSE2.value,
+ label=ChannelName.PULSE2.value,
),
NameCase(
- generator=GeneratorName.TRIANGLE,
+ channel=ChannelName.TRIANGLE,
expected="Kick (triangle)",
- label=GeneratorName.TRIANGLE.value,
+ label=ChannelName.TRIANGLE.value,
),
NameCase(
- generator=GeneratorName.NOISE,
+ channel=ChannelName.NOISE,
expected="Kick (noise)",
- label=GeneratorName.NOISE.value,
+ label=ChannelName.NOISE.value,
),
)
@@ -45,11 +45,11 @@ def test_the_generator_follows_the_base_name_in_parentheses(
self,
case: NameCase,
) -> None:
- assert instrument_slice_name(BASE_NAME, case.generator) == case.expected
+ assert instrument_slice_name(BASE_NAME, case.channel) == 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())
+ names = {instrument_slice_name(BASE_NAME, channel) for channel in ChannelName.items()}
+ assert len(names) == len(ChannelName.items())
def test_the_base_name_is_carried_verbatim(self) -> None:
- assert instrument_slice_name("Lead 2 (alt)", GeneratorName.PULSE1).startswith("Lead 2 (alt) ")
+ assert instrument_slice_name("Lead 2 (alt)", ChannelName.PULSE1).startswith("Lead 2 (alt) ")
diff --git a/tests/unit/sampletones_core/exporters/test_slices.py b/tests/unit/sampletones_core/exporters/test_slices.py
index 856bb3bd4..dffffd59b 100644
--- a/tests/unit/sampletones_core/exporters/test_slices.py
+++ b/tests/unit/sampletones_core/exporters/test_slices.py
@@ -2,71 +2,218 @@
import numpy as np
-from sampletones_core.constants.enums import FeatureKey, GeneratorName
-from sampletones_core.exporters.slices import iterate_sample_slices
-from sampletones_core.project.instruments.sample import Sample
+from sampletones_core.constants.enums import ChannelName, FeatureKey
+from sampletones_core.exporters.slices import (
+ FIRST_INSTRUMENT_INDEX,
+ InstrumentEntry,
+ iterate_instrument_entries,
+ iterate_voice_slices,
+ voice_instrument_entries,
+)
+from sampletones_core.features.envelope import Envelope
from sampletones_core.project.project import Project
from sampletones_core.project.settings import ProjectSettings
+from sampletones_core.project.voices.envelopes import InstrumentEnvelopes
+from sampletones_core.project.voices.instrument import Instrument
+from sampletones_core.project.voices.sample import Sample
+from sampletones_core.project.voices.voice import VoiceUnion
from sampletones_core.structures import IdentifiedCollection
from tests.suite.sequencer import sample_reconstruction
-def _project(samples: Sequence[Sample]) -> Project:
- collection: IdentifiedCollection[Sample] = IdentifiedCollection()
- for sample in samples:
- collection.append(sample)
+def _project(voices: Sequence[VoiceUnion]) -> Project:
+ collection: IdentifiedCollection[VoiceUnion] = IdentifiedCollection()
+ for voice in voices:
+ collection.append(voice)
project = Project.create(title="Slices", author="Tester", settings=ProjectSettings())
- project.samples = collection
+ project.voices = collection
return project
-def _sample(name: str, generators: Sequence[GeneratorName]) -> Sample:
- return Sample(name=name, reconstruction=sample_reconstruction(list(generators)))
+def _sample(name: str, channels: Sequence[ChannelName]) -> Sample:
+ return Sample(name=name, reconstruction=sample_reconstruction(list(channels)))
-class TestSampleSlices:
- """The walk numbers the instruments a module writes, so it visits the channels that play.
+def _instrument(name: str) -> Instrument:
+ return Instrument(
+ name=name, envelopes=InstrumentEnvelopes(volume=Envelope(items=(15, 10)), arpeggio=Envelope(items=(0, 5)))
+ )
- A sample carries every channel whatever it sounds, and one standing by is written nowhere,
- so it takes no place in the instrument table and shifts no index behind it.
- """
+
+def _stand_by(sample: Sample, channel: ChannelName) -> None:
+ sample.reconstruction.update_channel_data(
+ channel,
+ [],
+ np.zeros(0, dtype=np.float32),
+ sample.reconstruction.initial_pitches[channel],
+ (FeatureKey.VOLUME, FeatureKey.ARPEGGIO, FeatureKey.DUTY_CYCLE),
+ )
+
+
+class TestVoiceSlices:
+ """What every channel of every voice plays, which is what a per-channel backend reads."""
def test_a_sample_contributes_one_slice_per_playing_channel(self) -> None:
- project = _project([_sample("lead", [GeneratorName.PULSE1, GeneratorName.NOISE])])
+ project = _project([_sample("lead", [ChannelName.PULSE1, ChannelName.NOISE])])
- slices = list(iterate_sample_slices(project))
+ slices = list(iterate_voice_slices(project))
- assert [sample_slice.generator for sample_slice in slices] == [
- GeneratorName.PULSE1,
- GeneratorName.NOISE,
+ assert [voice_slice.channel for voice_slice in slices] == [
+ ChannelName.PULSE1,
+ ChannelName.NOISE,
]
- def test_a_channel_standing_by_takes_no_place_in_the_table(self) -> None:
- sample = _sample("lead", [GeneratorName.PULSE1, GeneratorName.PULSE2])
- sample.reconstruction.update_generator_data(
- GeneratorName.PULSE1,
- [],
- np.zeros(0, dtype=np.float32),
- sample.reconstruction.initial_pitches[GeneratorName.PULSE1],
- (FeatureKey.VOLUME, FeatureKey.ARPEGGIO, FeatureKey.DUTY_CYCLE),
- )
+ def test_a_channel_standing_by_takes_no_slice(self) -> None:
+ sample = _sample("lead", [ChannelName.PULSE1, ChannelName.PULSE2])
+ _stand_by(sample, ChannelName.PULSE1)
project = _project([sample])
- slices = list(iterate_sample_slices(project))
+ slices = list(iterate_voice_slices(project))
- assert [(sample_slice.index, sample_slice.generator) for sample_slice in slices] == [
- (0, GeneratorName.PULSE2),
- ]
+ assert [voice_slice.channel for voice_slice in slices] == [ChannelName.PULSE2]
+
+ def test_an_instrument_contributes_a_slice_for_every_channel_it_sounds_on(self) -> None:
+ project = _project([_instrument("lead")])
+
+ slices = list(iterate_voice_slices(project))
+
+ assert [voice_slice.channel for voice_slice in slices] == ChannelName.items()
+
+ def test_each_of_an_instruments_slices_is_measured_against_that_channels_root(self) -> None:
+ instrument = _instrument("lead")
+ project = _project([instrument])
+
+ for voice_slice in iterate_voice_slices(project):
+ assert voice_slice.features.initial_pitch == instrument.reference(voice_slice.channel)
+
+ def test_an_instrument_writing_nothing_contributes_no_slice(self) -> None:
+ project = _project([Instrument(name="empty")])
+
+ assert list(iterate_voice_slices(project)) == []
+
+
+class TestInstrumentEntries:
+ """The instruments an export writes, and the channels whose rows reach each one."""
- def test_slices_are_numbered_across_the_samples_in_order(self) -> None:
+ def test_a_sample_yields_one_instrument_per_playing_channel(self) -> None:
+ project = _project([_sample("lead", [ChannelName.PULSE1, ChannelName.NOISE])])
+
+ entries = list(iterate_instrument_entries(project))
+
+ assert [entry.index for entry in entries] == [0, 1]
+ assert [list(entry.slots) for entry in entries] == [[ChannelName.PULSE1], [ChannelName.NOISE]]
+
+ def test_an_instrument_takes_one_table_entry_every_channel_reaches(self) -> None:
+ project = _project([_instrument("lead")])
+
+ entries = list(iterate_instrument_entries(project))
+
+ assert len(entries) == 1
+ assert list(entries[0].slots) == ChannelName.items()
+ assert {slot.index for slot in entries[0].slots.values()} == {0}
+
+ def test_an_instruments_slots_each_carry_that_channels_root(self) -> None:
+ instrument = _instrument("lead")
+ project = _project([instrument])
+
+ entry = next(iter(iterate_instrument_entries(project)))
+
+ for channel, slot in entry.slots.items():
+ assert slot.initial_pitch == instrument.reference(channel)
+
+ def test_an_instrument_is_named_by_itself_and_a_sample_slice_by_its_channel(self) -> None:
+ project = _project([_instrument("lead"), _sample("pad", [ChannelName.TRIANGLE])])
+
+ entries = list(iterate_instrument_entries(project))
+
+ assert entries[0].name == "lead"
+ assert entries[1].name == "pad (triangle)"
+
+ def test_instruments_are_numbered_across_the_voices_in_order(self) -> None:
project = _project(
[
- _sample("lead", [GeneratorName.PULSE1]),
- _sample("pad", [GeneratorName.TRIANGLE, GeneratorName.NOISE]),
+ _sample("lead", [ChannelName.PULSE1]),
+ _instrument("hand"),
+ _sample("pad", [ChannelName.TRIANGLE, ChannelName.NOISE]),
]
)
- indices: List[int] = [sample_slice.index for sample_slice in iterate_sample_slices(project)]
+ indices: List[int] = [entry.index for entry in iterate_instrument_entries(project)]
+
+ assert indices == [0, 1, 2, 3]
+
+ def test_a_channel_standing_by_shifts_no_index_behind_it(self) -> None:
+ sample = _sample("lead", [ChannelName.PULSE1, ChannelName.PULSE2])
+ _stand_by(sample, ChannelName.PULSE1)
+ project = _project([sample])
+
+ entries = list(iterate_instrument_entries(project))
+
+ assert [(entry.index, list(entry.slots)) for entry in entries] == [(0, [ChannelName.PULSE2])]
+
+ def test_an_instrument_writing_nothing_takes_no_place_in_the_table(self) -> None:
+ project = _project([Instrument(name="empty"), _sample("pad", [ChannelName.TRIANGLE])])
+
+ entries = list(iterate_instrument_entries(project))
+
+ assert [entry.index for entry in entries] == [0]
+ assert entries[0].name == "pad (triangle)"
+
+
+class TestOneVoicesInstruments:
+ """The instruments one voice offers, which is the rule a whole-project export also reads."""
+
+ @staticmethod
+ def _entries(voice: VoiceUnion) -> List[InstrumentEntry]:
+ return list(voice_instrument_entries(voice, start_index=FIRST_INSTRUMENT_INDEX))
+
+ def test_a_sample_offers_one_per_playing_channel(self) -> None:
+ sample = _sample("bass", [ChannelName.PULSE1, ChannelName.NOISE])
+
+ assert [entry.channel for entry in self._entries(sample)] == [
+ ChannelName.PULSE1,
+ ChannelName.NOISE,
+ ]
+
+ def test_a_written_instrument_offers_one(self) -> None:
+ """One set of envelopes every channel reads is one instrument, however many it sounds on."""
+ assert len(self._entries(_instrument("lead"))) == 1
+
+ def test_a_written_instrument_states_its_envelopes_for_no_channel(self) -> None:
+ """One set every channel reads belongs to none of them, so the entry names none."""
+ assert self._entries(_instrument("lead"))[0].channel is None
+
+ def test_a_written_instrument_still_answers_for_every_channel_it_sounds_on(self) -> None:
+ """Naming no channel of its own leaves the rows of every channel reaching it."""
+ assert list(self._entries(_instrument("lead"))[0].slots) == ChannelName.items()
+
+ def test_a_samples_slice_names_the_channel_it_was_reconstructed_for(self) -> None:
+ sample = _sample("bass", [ChannelName.TRIANGLE])
+
+ assert self._entries(sample)[0].channel is ChannelName.TRIANGLE
+
+ def test_a_voice_writing_nothing_offers_nothing(self) -> None:
+ assert self._entries(_instrument_writing_nothing()) == []
+
+ def test_the_project_walk_reads_the_same_rule(self) -> None:
+ """A reader is offered exactly the instruments a module would have held for that voice."""
+ sample = _sample("bass", [ChannelName.PULSE1, ChannelName.NOISE])
+ instrument = _instrument("lead")
+ project = _project([sample, instrument])
+
+ walked = list(iterate_instrument_entries(project))
+ per_voice = self._entries(sample) + self._entries(instrument)
+
+ assert [entry.name for entry in walked] == [entry.name for entry in per_voice]
+
+ def test_the_numbering_starts_where_it_is_told_to(self) -> None:
+ sample = _sample("bass", [ChannelName.PULSE1, ChannelName.NOISE])
+
+ entries = list(voice_instrument_entries(sample, start_index=7))
+
+ assert [entry.index for entry in entries] == [7, 8]
+
- assert indices == [0, 1, 2]
+def _instrument_writing_nothing() -> Instrument:
+ return Instrument(name="silent", envelopes=InstrumentEnvelopes())
diff --git a/tests/unit/sampletones_core/exporters/test_truncation.py b/tests/unit/sampletones_core/exporters/test_truncation.py
index 71803658d..9249292a6 100644
--- a/tests/unit/sampletones_core/exporters/test_truncation.py
+++ b/tests/unit/sampletones_core/exporters/test_truncation.py
@@ -1,29 +1,10 @@
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
diff --git a/tests/unit/sampletones_core/exports/__init__.py b/tests/unit/sampletones_core/exports/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/tests/unit/sampletones_core/trackers/test_bitphase.py b/tests/unit/sampletones_core/exports/test_bitphase.py
similarity index 90%
rename from tests/unit/sampletones_core/trackers/test_bitphase.py
rename to tests/unit/sampletones_core/exports/test_bitphase.py
index 4c9aa41fd..8cb5466e6 100644
--- a/tests/unit/sampletones_core/trackers/test_bitphase.py
+++ b/tests/unit/sampletones_core/exports/test_bitphase.py
@@ -7,21 +7,23 @@
import numpy as np
import pytest
-from sampletones_core.constants.enums import GeneratorName
+from sampletones_core.constants.enums import ChannelName
from sampletones_core.exporters import Features
-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 (
+from sampletones_core.exports.format import ExportFormat
+from sampletones_core.exports.implementation.bitphase import (
BitphaseBackend,
BitphasePresetBackend,
)
-from sampletones_core.trackers.request import (
+from sampletones_core.exports.request import (
InstrumentExport,
ProjectExport,
SampleExport,
)
-from sampletones_core.trackers.scope import ExportScope
+from sampletones_core.exports.scope import ExportScope
+from sampletones_core.features.envelope import Envelope
+from sampletones_core.project.project import Project
+from sampletones_core.project.settings import ProjectSettings
+from sampletones_shared.music import Tuning
from sampletones_shared.paths.extensions import EXT_FILE_BITPHASE, EXT_FILE_JSON
NES_FREQUENCY: Final[int] = 60
@@ -38,8 +40,8 @@ def build_features(frames: int, *, duty_cycle_frames: Optional[int] = None) -> F
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),
+ volume=Envelope(items=(15,) * frames),
+ arpeggio=Envelope(items=(0,) * frames),
pitch=None,
hi_pitch=None,
duty_cycle=duty_cycle,
@@ -49,15 +51,20 @@ def build_features(frames: int, *, duty_cycle_frames: Optional[int] = None) -> F
def build_instrument(name: str, frames: int) -> InstrumentExport:
return InstrumentExport(
name=name,
- generator=GeneratorName.PULSE1,
+ channel=ChannelName.PULSE1,
features=build_features(frames),
- loop=False,
nes_frequency=NES_FREQUENCY,
+ tuning=Tuning(),
)
def build_sample(name: str, *instruments: InstrumentExport) -> SampleExport:
- return SampleExport(name=name, instruments=instruments, nes_frequency=NES_FREQUENCY)
+ return SampleExport(
+ name=name,
+ instruments=instruments,
+ nes_frequency=NES_FREQUENCY,
+ tuning=Tuning(),
+ )
def read_document(destination: Path) -> Dict[str, Any]:
@@ -82,7 +89,7 @@ def project_fixture() -> Project:
class TestFormatDeclaration:
def test_the_backend_names_its_format(self, backend: BitphaseBackend) -> None:
- assert backend.tracker_format == TrackerFormat.BITPHASE
+ assert backend.export_format == ExportFormat.BITPHASE
def test_every_scope_is_supported(self, backend: BitphaseBackend) -> None:
assert backend.supported_scopes == frozenset(ExportScope)
@@ -171,7 +178,7 @@ def test_the_document_takes_the_project_title(
class TestThePresetBackend:
def test_the_backend_names_its_format(self, preset_backend: BitphasePresetBackend) -> None:
- assert preset_backend.tracker_format == TrackerFormat.BITPHASE_PRESET
+ assert preset_backend.export_format == ExportFormat.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})
diff --git a/tests/unit/sampletones_core/trackers/test_extensions.py b/tests/unit/sampletones_core/exports/test_extensions.py
similarity index 73%
rename from tests/unit/sampletones_core/trackers/test_extensions.py
rename to tests/unit/sampletones_core/exports/test_extensions.py
index b9cc907d1..675b7480d 100644
--- a/tests/unit/sampletones_core/trackers/test_extensions.py
+++ b/tests/unit/sampletones_core/exports/test_extensions.py
@@ -3,11 +3,11 @@
import pytest
-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
+from sampletones_core.exports.backend import ExportBackend
+from sampletones_core.exports.extensions import format_for_extension
+from sampletones_core.exports.format import ExportFormat
+from sampletones_core.exports.registry import build_tracker_backends
+from sampletones_core.exports.scope import ExportScope
from sampletones_shared.paths.extensions import (
EXT_FILE_BITPHASE,
EXT_FILE_INSTRUMENT,
@@ -23,34 +23,34 @@
class ExtensionCase:
scope: ExportScope
extension: str
- expected: Optional[TrackerFormat]
+ expected: Optional[ExportFormat]
EXTENSION_CASES: Final[List[ExtensionCase]] = [
ExtensionCase(
scope=ExportScope.INSTRUMENT,
extension=EXT_FILE_INSTRUMENT,
- expected=TrackerFormat.FAMITRACKER,
+ expected=ExportFormat.FAMITRACKER,
),
ExtensionCase(
scope=ExportScope.INSTRUMENT,
extension=EXT_FILE_BITPHASE,
- expected=TrackerFormat.BITPHASE,
+ expected=ExportFormat.BITPHASE,
),
ExtensionCase(
scope=ExportScope.INSTRUMENT,
extension=EXT_FILE_JSON,
- expected=TrackerFormat.BITPHASE_PRESET,
+ expected=ExportFormat.BITPHASE_PRESET,
),
ExtensionCase(
scope=ExportScope.SAMPLE,
extension=EXT_FILE_JSON,
- expected=TrackerFormat.BITPHASE_PRESET,
+ expected=ExportFormat.BITPHASE_PRESET,
),
ExtensionCase(
scope=ExportScope.PROJECT,
extension=EXT_FILE_MODULE,
- expected=TrackerFormat.FAMITRACKER,
+ expected=ExportFormat.FAMITRACKER,
),
ExtensionCase(
scope=ExportScope.INSTRUMENT,
@@ -66,7 +66,7 @@ class ExtensionCase:
@pytest.fixture(name="backends")
-def backends_fixture() -> Dict[TrackerFormat, TrackerBackend]:
+def backends_fixture() -> Dict[ExportFormat, ExportBackend]:
return build_tracker_backends()
@@ -78,23 +78,23 @@ class TestFormatForExtension:
)
def test_the_extension_names_the_format_that_writes_it(
self,
- backends: Dict[TrackerFormat, TrackerBackend],
+ backends: Dict[ExportFormat, ExportBackend],
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],
+ backends: Dict[ExportFormat, ExportBackend],
) -> None:
assert (
format_for_extension(backends, ExportScope.INSTRUMENT, EXT_FILE_INSTRUMENT.upper())
- == TrackerFormat.FAMITRACKER
+ == ExportFormat.FAMITRACKER
)
def test_a_format_that_cannot_express_the_scope_stays_unmatched(
self,
- backends: Dict[TrackerFormat, TrackerBackend],
+ backends: Dict[ExportFormat, ExportBackend],
) -> None:
"""A preset holds one instrument, so a project named with its extension resolves
to no format at all.
@@ -104,13 +104,13 @@ def test_a_format_that_cannot_express_the_scope_stays_unmatched(
@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],
+ backends: Dict[ExportFormat, ExportBackend],
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():
+ for export_format, backend in backends.items():
if scope in backend.supported_scopes:
- assert format_for_extension(backends, scope, backend.extension(scope)) == tracker_format
+ assert format_for_extension(backends, scope, backend.extension(scope)) == export_format
diff --git a/tests/unit/sampletones_core/trackers/test_famitracker.py b/tests/unit/sampletones_core/exports/test_famitracker.py
similarity index 63%
rename from tests/unit/sampletones_core/trackers/test_famitracker.py
rename to tests/unit/sampletones_core/exports/test_famitracker.py
index 3f036b020..645831344 100644
--- a/tests/unit/sampletones_core/trackers/test_famitracker.py
+++ b/tests/unit/sampletones_core/exports/test_famitracker.py
@@ -4,27 +4,36 @@
import numpy as np
import pytest
-from sampletones_core.constants.enums import GeneratorName
+from sampletones_core.constants.enums import ChannelName
from sampletones_core.exporters import Features
from sampletones_core.exporters.truncation import EnvelopeTruncation
+from sampletones_core.exports.format import ExportFormat
+from sampletones_core.exports.implementation.famitracker import FamiTrackerBackend
+from sampletones_core.exports.progress import ExportProgress
+from sampletones_core.exports.request import InstrumentExport, SampleExport
+from sampletones_core.exports.scope import ExportScope
+from sampletones_core.exports.stage import ExportStage
+from sampletones_core.features.envelope import Envelope
from sampletones_core.formats.famitracker.specification.sequences import (
MAX_SEQUENCE_ITEMS,
)
-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
+from sampletones_shared.exceptions import OperationCanceled
+from sampletones_shared.music import Tuning
from sampletones_shared.paths.extensions import EXT_FILE_INSTRUMENT, EXT_FILE_MODULE
+from tests.suite.progress import RecordingReporter
NES_FREQUENCY: Final[int] = 60
+ENVELOPE_FRAMES: Final[int] = 4
+AFTER_THE_FIRST_FILE: Final[int] = 2
+ONE_FILE: Final[int] = 1
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),
+ volume=Envelope(items=(15,) * frames),
+ arpeggio=Envelope(items=(0,) * frames),
pitch=None,
hi_pitch=None,
duty_cycle=duty_cycle,
@@ -34,15 +43,20 @@ def build_features(frames: int, *, duty_cycle_frames: Optional[int] = None) -> F
def build_instrument(name: str, frames: int) -> InstrumentExport:
return InstrumentExport(
name=name,
- generator=GeneratorName.PULSE1,
+ channel=ChannelName.PULSE1,
features=build_features(frames),
- loop=False,
nes_frequency=NES_FREQUENCY,
+ tuning=Tuning(),
)
def build_sample(name: str, *instruments: InstrumentExport) -> SampleExport:
- return SampleExport(name=name, instruments=instruments, nes_frequency=NES_FREQUENCY)
+ return SampleExport(
+ name=name,
+ instruments=instruments,
+ nes_frequency=NES_FREQUENCY,
+ tuning=Tuning(),
+ )
@pytest.fixture(name="backend")
@@ -52,7 +66,7 @@ def backend_fixture() -> FamiTrackerBackend:
class TestFormatDeclaration:
def test_the_backend_names_its_format(self, backend: FamiTrackerBackend) -> None:
- assert backend.tracker_format == TrackerFormat.FAMITRACKER
+ assert backend.export_format == ExportFormat.FAMITRACKER
def test_every_scope_is_supported(self, backend: FamiTrackerBackend) -> None:
assert backend.supported_scopes == frozenset(ExportScope)
@@ -161,3 +175,46 @@ def test_slices_that_all_fit_report_nothing(self, backend: FamiTrackerBackend, t
artifact = backend.write_sample(tmp_path / "Kick", request)
assert artifact.truncation is None
+
+
+class TestWhatABatchSaysAboutItself:
+ """A reconstruction lands as a file per slice, so the run counts them as it writes them."""
+
+ def test_the_run_counts_every_slice_it_writes(self, backend: FamiTrackerBackend, tmp_path: Path) -> None:
+ reporter: RecordingReporter[ExportProgress] = RecordingReporter()
+ sample = build_sample(
+ "kit",
+ build_instrument("lead", ENVELOPE_FRAMES),
+ build_instrument("bass", ENVELOPE_FRAMES),
+ )
+ backend.write_sample(tmp_path / f"kit{EXT_FILE_INSTRUMENT}", sample, reporter)
+ assert reporter.last == ExportProgress(
+ stage=ExportStage.WRITING,
+ completed=len(sample.instruments),
+ total=len(sample.instruments),
+ )
+
+ def test_a_withdrawn_batch_leaves_the_slices_it_had_not_reached(
+ self,
+ backend: FamiTrackerBackend,
+ tmp_path: Path,
+ ) -> None:
+ reporter: RecordingReporter[ExportProgress] = RecordingReporter(withdraw_at=AFTER_THE_FIRST_FILE)
+ sample = build_sample(
+ "kit",
+ build_instrument("lead", ENVELOPE_FRAMES),
+ build_instrument("bass", ENVELOPE_FRAMES),
+ )
+ with pytest.raises(OperationCanceled):
+ backend.write_sample(tmp_path / f"kit{EXT_FILE_INSTRUMENT}", sample, reporter)
+
+ assert not (tmp_path / f"bass{EXT_FILE_INSTRUMENT}").exists()
+
+ def test_a_module_reports_the_one_file_it_writes(self, backend: FamiTrackerBackend, tmp_path: Path) -> None:
+ reporter: RecordingReporter[ExportProgress] = RecordingReporter()
+ backend.write_instrument(
+ tmp_path / f"lead{EXT_FILE_INSTRUMENT}",
+ build_instrument("lead", ENVELOPE_FRAMES),
+ reporter,
+ )
+ assert reporter.last == ExportProgress(stage=ExportStage.WRITING, completed=ONE_FILE, total=ONE_FILE)
diff --git a/tests/unit/sampletones_core/exports/test_progress.py b/tests/unit/sampletones_core/exports/test_progress.py
new file mode 100644
index 000000000..c1c9ae047
--- /dev/null
+++ b/tests/unit/sampletones_core/exports/test_progress.py
@@ -0,0 +1,47 @@
+from typing import Final
+
+import pytest
+
+from sampletones_core.exports.progress import (
+ ExportProgress,
+ announce,
+)
+from sampletones_core.exports.stage import ExportStage
+from sampletones_shared.exceptions import OperationCanceled
+from sampletones_shared.utils.progress import silent_reporter
+from tests.suite.progress import FIRST_REPORT, RecordingReporter
+
+WRITTEN: Final[int] = 3
+TO_WRITE: Final[int] = 8
+UNMEASURED: None = None
+
+
+class TestWhatACallerHearsFromAnExport:
+ """A run says which stage it is in and how far that stage has come."""
+
+ def test_a_stage_reaches_the_caller_with_what_it_has_covered(self) -> None:
+ reporter: RecordingReporter[ExportProgress] = RecordingReporter()
+ announce(reporter, ExportStage.WRITING, WRITTEN, TO_WRITE)
+ assert reporter.last == ExportProgress(stage=ExportStage.WRITING, completed=WRITTEN, total=TO_WRITE)
+
+ def test_a_stage_only_the_data_ends_states_no_length(self) -> None:
+ reporter: RecordingReporter[ExportProgress] = RecordingReporter()
+ announce(reporter, ExportStage.COMPRESSING, WRITTEN, UNMEASURED)
+ assert reporter.last.total is None
+
+ def test_a_caller_watching_nothing_lets_every_stage_through(self) -> None:
+ announce(silent_reporter, ExportStage.WALKING, WRITTEN, TO_WRITE)
+
+
+class TestWithdrawingARun:
+ """A caller that stops wanting the answer stops the run producing it."""
+
+ def test_a_withdrawn_run_unwinds_where_it_was_told(self) -> None:
+ reporter: RecordingReporter[ExportProgress] = RecordingReporter(withdraw_at=FIRST_REPORT)
+ with pytest.raises(OperationCanceled):
+ announce(reporter, ExportStage.WRITING, WRITTEN, TO_WRITE)
+
+ def test_a_withdrawal_names_the_stage_it_landed_on(self) -> None:
+ reporter: RecordingReporter[ExportProgress] = RecordingReporter(withdraw_at=FIRST_REPORT)
+ with pytest.raises(OperationCanceled, match=ExportStage.WRITING.value):
+ announce(reporter, ExportStage.WRITING, WRITTEN, TO_WRITE)
diff --git a/tests/unit/sampletones_core/features/__init__.py b/tests/unit/sampletones_core/features/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/tests/unit/sampletones_core/features/test_envelope.py b/tests/unit/sampletones_core/features/test_envelope.py
new file mode 100644
index 000000000..fea15e1a9
--- /dev/null
+++ b/tests/unit/sampletones_core/features/test_envelope.py
@@ -0,0 +1,121 @@
+from dataclasses import dataclass
+from typing import Final, Optional, Tuple
+
+import pytest
+from pydantic import ValidationError
+
+from sampletones_core.features.envelope import Envelope
+from tests.suite.base import BaseTestSuite
+from tests.suite.case import BaseRegularTestCase
+
+ITEM_LIMIT: Final[int] = 252
+
+
+def items_of(length: int) -> Tuple[int, ...]:
+ return tuple(index % 16 for index in range(length))
+
+
+class TestWhatADimensionHoldsAtATick(BaseTestSuite):
+ """A dimension answers at every tick, which is what lets a note sound past its written frames."""
+
+ @dataclass(frozen=True, kw_only=True)
+ class TestCase(BaseRegularTestCase):
+ items: Tuple[int, ...]
+ loop_point: Optional[int]
+ expected: Tuple[Optional[int], ...]
+
+ test_cases = (
+ TestCase(
+ label="a dimension the channel governs answers nothing",
+ items=(),
+ loop_point=None,
+ expected=(None, None, None),
+ ),
+ TestCase(
+ label="a dimension halting holds its final value",
+ items=(15, 8, 4),
+ loop_point=None,
+ expected=(15, 8, 4, 4, 4),
+ ),
+ TestCase(
+ label="a dimension looping circles from its point",
+ items=(15, 8, 4),
+ loop_point=1,
+ expected=(15, 8, 4, 8, 4, 8),
+ ),
+ TestCase(
+ label="a dimension looping from the start circles whole",
+ items=(9, 3),
+ loop_point=0,
+ expected=(9, 3, 9, 3, 9),
+ ),
+ )
+
+ @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label)
+ def test_the_value_at_each_tick(self, test_case: TestCase) -> None:
+ envelope = Envelope[int](items=test_case.items, loop_point=test_case.loop_point)
+
+ assert tuple(envelope.at(tick) for tick in range(len(test_case.expected))) == test_case.expected
+
+
+class TestWhatADimensionStates:
+ def test_a_written_dimension_takes_itself_out_of_the_channels_own(self) -> None:
+ assert Envelope[int](items=(15,)).written
+
+ def test_an_empty_dimension_is_left_to_the_channel(self) -> None:
+ assert not Envelope[int]().written
+
+ def test_a_dimension_states_whether_it_circles(self) -> None:
+ assert Envelope[int](items=(15, 8), loop_point=0).loops
+ assert not Envelope[int](items=(15, 8)).loops
+
+ def test_a_point_past_the_items_written_is_refused(self) -> None:
+ with pytest.raises(ValidationError):
+ Envelope[int](items=(15, 8), loop_point=2)
+
+ def test_a_point_on_a_dimension_writing_nothing_is_refused(self) -> None:
+ with pytest.raises(ValidationError):
+ Envelope[int](items=(), loop_point=0)
+
+
+class TestADimensionWithinALimit:
+ """``limited`` states no limit of its own: whoever asks for one is where it comes from."""
+
+ def test_a_dimension_within_the_limit_stands_as_written(self) -> None:
+ envelope = Envelope[int](items=(15, 12, 9, 0))
+
+ assert envelope.limited(ITEM_LIMIT) == envelope
+
+ def test_an_over_long_dimension_keeps_its_opening_items(self) -> None:
+ limited = Envelope[int](items=items_of(ITEM_LIMIT + 48)).limited(ITEM_LIMIT)
+
+ assert limited.items == items_of(ITEM_LIMIT)
+
+ def test_a_point_past_what_survives_moves_to_the_last_item_kept(self) -> None:
+ limited = Envelope[int](items=items_of(ITEM_LIMIT + 48), loop_point=ITEM_LIMIT + 10).limited(ITEM_LIMIT)
+
+ assert limited.loop_point == ITEM_LIMIT - 1
+
+ def test_a_point_inside_what_survives_stays_where_it_was(self) -> None:
+ limited = Envelope[int](items=items_of(ITEM_LIMIT + 48), loop_point=4).limited(ITEM_LIMIT)
+
+ assert limited.loop_point == 4
+
+
+class TestADimensionBroughtToALength:
+ def test_a_shorter_dimension_holds_its_final_value(self) -> None:
+ assert Envelope[int](items=(0, 2, 4)).resized(5).items == (0, 2, 4, 4, 4)
+
+ def test_a_dimension_at_the_length_stands_as_written(self) -> None:
+ envelope = Envelope[int](items=(15, 12, 9))
+
+ assert envelope.resized(3) == envelope
+
+ def test_a_dimension_the_channel_governs_stays_empty(self) -> None:
+ assert Envelope[int]().resized(5).items == ()
+
+ def test_a_point_stays_inside_the_items_kept(self) -> None:
+ resized = Envelope[int](items=(15, 12, 9, 0), loop_point=3).resized(2)
+
+ assert resized.items == (15, 12)
+ assert resized.loop_point == 1
diff --git a/tests/unit/sampletones_core/features/test_spec.py b/tests/unit/sampletones_core/features/test_spec.py
index 012d27341..e86ce81f1 100644
--- a/tests/unit/sampletones_core/features/test_spec.py
+++ b/tests/unit/sampletones_core/features/test_spec.py
@@ -1,15 +1,16 @@
from sampletones_core.constants.enums import (
+ ChannelName,
FeatureKey,
GeneratorName,
- LibraryGeneratorName,
)
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.features import (
+ CHANNEL_GENERATOR_KIND,
FEATURE_DIMENSION_ORDER,
- GENERATOR_KIND,
feature_range,
+ generator_channel,
supported_features,
supports,
)
@@ -20,41 +21,52 @@
def test_supported_features_follow_dimension_order() -> None:
- assert supported_features(LibraryGeneratorName.PULSE) == [
- FeatureKey.VOLUME,
- FeatureKey.ARPEGGIO,
- FeatureKey.DUTY_CYCLE,
- ]
- assert supported_features(LibraryGeneratorName.TRIANGLE) == [
- FeatureKey.VOLUME,
- FeatureKey.ARPEGGIO,
- ]
- assert supported_features(LibraryGeneratorName.NOISE) == [
- FeatureKey.VOLUME,
- FeatureKey.ARPEGGIO,
- FeatureKey.DUTY_CYCLE,
- ]
+ for generator_name in GeneratorName:
+ offered = supported_features(generator_name)
+
+ assert offered == [feature_key for feature_key in FEATURE_DIMENSION_ORDER if feature_key in offered]
+
+
+def test_a_generator_offers_every_dimension_it_states_a_range_for() -> None:
+ for generator_name in GeneratorName:
+ offered = set(supported_features(generator_name))
+
+ assert offered == {
+ feature_key for feature_key in FEATURE_DIMENSION_ORDER if supports(generator_name, feature_key)
+ }
+
+
+def test_the_noise_channel_reads_no_bend() -> None:
+ """Its sixteen periods have no finer grid, so a bend would state a resolution it lacks."""
+ assert not supports(GeneratorName.NOISE, FeatureKey.PITCH)
+ assert not supports(GeneratorName.NOISE, FeatureKey.HI_PITCH)
+
+
+def test_the_tonal_channels_read_both_bend_dimensions() -> None:
+ for generator_name in (GeneratorName.PULSE, GeneratorName.TRIANGLE):
+ assert supports(generator_name, FeatureKey.PITCH)
+ assert supports(generator_name, FeatureKey.HI_PITCH)
def test_feature_ranges_match_expected_channel_domains() -> None:
- assert feature_range(LibraryGeneratorName.PULSE, FeatureKey.DUTY_CYCLE) == feature_range(
- GENERATOR_KIND[GeneratorName.PULSE1],
+ assert feature_range(GeneratorName.PULSE, FeatureKey.DUTY_CYCLE) == feature_range(
+ CHANNEL_GENERATOR_KIND[ChannelName.PULSE1],
FeatureKey.DUTY_CYCLE,
)
- assert feature_range(LibraryGeneratorName.NOISE, FeatureKey.DUTY_CYCLE).maximum == 1
- assert feature_range(LibraryGeneratorName.NOISE, FeatureKey.ARPEGGIO).minimum == 0
- assert feature_range(LibraryGeneratorName.NOISE, FeatureKey.ARPEGGIO).maximum == 15
+ assert feature_range(GeneratorName.NOISE, FeatureKey.DUTY_CYCLE).maximum == 1
+ assert feature_range(GeneratorName.NOISE, FeatureKey.ARPEGGIO).minimum == 0
+ assert feature_range(GeneratorName.NOISE, FeatureKey.ARPEGGIO).maximum == 15
def test_supports_reports_triangle_lacks_duty_cycle() -> None:
- assert supports(LibraryGeneratorName.TRIANGLE, FeatureKey.VOLUME)
- assert not supports(LibraryGeneratorName.TRIANGLE, FeatureKey.DUTY_CYCLE)
+ assert supports(GeneratorName.TRIANGLE, FeatureKey.VOLUME)
+ assert not supports(GeneratorName.TRIANGLE, FeatureKey.DUTY_CYCLE)
def test_supported_features_match_exporter_attribute_maps() -> None:
- assert tuple(supported_features(LibraryGeneratorName.PULSE)) == tuple(PulseExporter._ATTRIBUTE_MAP)
- assert tuple(supported_features(LibraryGeneratorName.TRIANGLE)) == tuple(TriangleExporter._ATTRIBUTE_MAP)
- assert tuple(supported_features(LibraryGeneratorName.NOISE)) == tuple(NoiseExporter._ATTRIBUTE_MAP)
+ assert tuple(supported_features(GeneratorName.PULSE)) == tuple(PulseExporter._ATTRIBUTE_MAP)
+ assert tuple(supported_features(GeneratorName.TRIANGLE)) == tuple(TriangleExporter._ATTRIBUTE_MAP)
+ assert tuple(supported_features(GeneratorName.NOISE)) == tuple(NoiseExporter._ATTRIBUTE_MAP)
def test_feature_dimension_order_matches_famitracker_sequence_slots() -> None:
@@ -66,3 +78,14 @@ def test_feature_dimension_order_matches_famitracker_sequence_slots() -> None:
SequenceKind.DUTY,
]
assert [FEATURE_KEY_TO_SEQUENCE_KIND[key] for key in FEATURE_DIMENSION_ORDER] == expected
+
+
+def test_a_generator_is_heard_on_the_first_channel_it_drives() -> None:
+ assert generator_channel(GeneratorName.PULSE) is ChannelName.PULSE1
+ assert generator_channel(GeneratorName.TRIANGLE) is ChannelName.TRIANGLE
+ assert generator_channel(GeneratorName.NOISE) is ChannelName.NOISE
+
+
+def test_every_generator_names_a_channel_that_reads_it_back() -> None:
+ for generator_name in GeneratorName:
+ assert CHANNEL_GENERATOR_KIND[generator_channel(generator_name)] is generator_name
diff --git a/tests/unit/sampletones_core/features/test_text.py b/tests/unit/sampletones_core/features/test_text.py
new file mode 100644
index 000000000..f8a005e48
--- /dev/null
+++ b/tests/unit/sampletones_core/features/test_text.py
@@ -0,0 +1,78 @@
+from typing import Optional, Tuple
+
+import pytest
+
+from sampletones_core.features.envelope import Envelope
+from sampletones_core.features.text import format_envelope, parse_envelope
+
+
+class TestWhatAReaderTypes:
+ @pytest.mark.parametrize(
+ ("text", "items", "loop_point"),
+ [
+ ("15 14 12 10", (15, 14, 12, 10), None),
+ ("15 14 | 12 10", (15, 14, 12, 10), 2),
+ ("| 15 14", (15, 14), 0),
+ ("0", (0,), None),
+ ("", (), None),
+ (" 15 14 ", (15, 14), None),
+ ("-4 0 7", (-4, 0, 7), None),
+ ],
+ )
+ def test_a_typed_sequence_states_its_values_and_the_item_they_repeat_from(
+ self,
+ text: str,
+ items: Tuple[int, ...],
+ loop_point: Optional[int],
+ ) -> None:
+ envelope = parse_envelope(text)
+
+ assert (envelope.items, envelope.loop_point) == (items, loop_point)
+
+ def test_the_point_names_the_item_it_stands_before(self) -> None:
+ """A reader places the bar where the sequence turns back, and that item is what plays next."""
+ envelope = parse_envelope("15 14 | 12 10")
+
+ assert envelope.at(4) == 12
+
+ @pytest.mark.parametrize(
+ "text",
+ [
+ "15 | 14 | 12",
+ "15 14 |",
+ "|",
+ "15 x 14",
+ "15 1.5",
+ "15 -",
+ ],
+ )
+ def test_a_sequence_that_states_no_dimension_is_refused(self, text: str) -> None:
+ with pytest.raises(ValueError):
+ parse_envelope(text)
+
+
+class TestWhatAReaderIsShown:
+ @pytest.mark.parametrize(
+ ("items", "loop_point", "text"),
+ [
+ ((15, 14, 12, 10), None, "15 14 12 10"),
+ ((15, 14, 12, 10), 2, "15 14 | 12 10"),
+ ((15, 14), 0, "| 15 14"),
+ ((15, 14), 1, "15 | 14"),
+ ((), None, ""),
+ ],
+ )
+ def test_a_dimension_is_written_out_as_it_would_be_typed(
+ self,
+ items: Tuple[int, ...],
+ loop_point: Optional[int],
+ text: str,
+ ) -> None:
+ assert format_envelope(Envelope[int](items=items, loop_point=loop_point)) == text
+
+ @pytest.mark.parametrize(
+ "text",
+ ["15 14 12 10", "15 14 | 12 10", "| 15 14", ""],
+ )
+ def test_what_is_shown_reads_back_as_what_it_shows(self, text: str) -> None:
+ assert format_envelope(parse_envelope(text)) == text
diff --git a/tests/unit/sampletones_core/fft/test_instantaneous.py b/tests/unit/sampletones_core/fft/test_instantaneous.py
new file mode 100644
index 000000000..0d9d73b3f
--- /dev/null
+++ b/tests/unit/sampletones_core/fft/test_instantaneous.py
@@ -0,0 +1,97 @@
+import math
+from typing import Final, List, Optional
+
+import numpy as np
+import pytest
+
+from sampletones_core.fft.instantaneous import FundamentalReading, InstantaneousPitch
+
+SAMPLE_RATE: Final[int] = 44100
+HOP: Final[int] = 735
+SECONDS: Final[float] = 0.5
+A4_FREQUENCY: Final[float] = 440.0
+CENTS_PER_OCTAVE: Final[float] = 1200.0
+CENT_TOLERANCE: Final[float] = 1.0
+PITCHED_CONFIDENCE: Final[float] = 0.2
+UNPITCHED_CONFIDENCE: Final[float] = 0.1
+NOISE_SEED: Final[int] = 4
+
+
+def _harmonic(frequency: float, seed: int = 0) -> np.ndarray:
+ """A steady tone with a full harmonic series, which is what a pitched frame looks like."""
+ generator = np.random.default_rng(seed)
+ count = int(SAMPLE_RATE * SECONDS)
+ time = np.arange(count) / SAMPLE_RATE
+ audio = np.zeros(count)
+ for harmonic in range(1, 20):
+ if frequency * harmonic > SAMPLE_RATE / 2:
+ break
+
+ audio += np.sin(2 * np.pi * frequency * harmonic * time + generator.uniform(0, 2 * np.pi)) / harmonic
+
+ return (audio / np.abs(audio).max() * 0.3).astype(np.float32)
+
+
+def _readings(audio: np.ndarray, reference: float) -> List[FundamentalReading]:
+ reader = InstantaneousPitch(audio, SAMPLE_RATE, HOP)
+ read: List[Optional[FundamentalReading]] = [reader.at(frame, reference) for frame in range(reader.columns)]
+ return [reading for reading in read if reading is not None]
+
+
+def _median_cents(readings: List[FundamentalReading], reference: float) -> float:
+ return CENTS_PER_OCTAVE * math.log2(float(np.median([reading.frequency for reading in readings])) / reference)
+
+
+class TestReadingAFundamental:
+ @pytest.mark.parametrize("cents", (-45.0, -18.0, 0.0, 25.0, 40.0), ids=lambda cents: f"{cents:+.0f}c")
+ def test_a_tone_between_two_notes_is_read_where_it_stands(self, cents: float) -> None:
+ """The bins are a semitone apart, and the phase places the tone far inside one of them."""
+ truth = A4_FREQUENCY * 2 ** (cents / CENTS_PER_OCTAVE)
+
+ readings = _readings(_harmonic(truth), A4_FREQUENCY)
+
+ assert readings
+ assert abs(_median_cents(readings, A4_FREQUENCY) - cents) < CENT_TOLERANCE
+
+ def test_a_reading_is_taken_for_every_frame_of_the_recording(self) -> None:
+ reader = InstantaneousPitch(_harmonic(A4_FREQUENCY), SAMPLE_RATE, HOP)
+
+ assert all(reader.at(frame, A4_FREQUENCY) is not None for frame in range(reader.columns))
+
+ def test_a_frame_outside_the_recording_is_read_as_nothing(self) -> None:
+ reader = InstantaneousPitch(_harmonic(A4_FREQUENCY), SAMPLE_RATE, HOP)
+
+ assert reader.at(-1, A4_FREQUENCY) is None
+ assert reader.at(reader.columns, A4_FREQUENCY) is None
+
+ def test_a_reference_the_transform_never_reaches_is_read_as_nothing(self) -> None:
+ reader = InstantaneousPitch(_harmonic(A4_FREQUENCY), SAMPLE_RATE, HOP)
+
+ assert reader.at(1, SAMPLE_RATE) is None
+
+
+class TestHowMuchOfAFrameStandsBehindItsReading:
+ def test_a_pitched_frame_reads_with_confidence(self) -> None:
+ readings = _readings(_harmonic(A4_FREQUENCY), A4_FREQUENCY)
+
+ assert float(np.median([reading.confidence for reading in readings])) > PITCHED_CONFIDENCE
+
+ def test_noise_reads_with_none(self) -> None:
+ """Noise spreads its energy everywhere, so the harmonics of any note hold little of it."""
+ count = int(SAMPLE_RATE * SECONDS)
+ noise = np.random.default_rng(NOISE_SEED).normal(0.0, 0.3, count).astype(np.float32)
+
+ readings = _readings(noise, A4_FREQUENCY)
+
+ assert float(np.median([reading.confidence for reading in readings])) < UNPITCHED_CONFIDENCE
+
+ def test_a_tone_sharing_the_frame_lowers_the_share_without_moving_the_reading(self) -> None:
+ alone = _harmonic(A4_FREQUENCY)
+ crowded = alone + _harmonic(A4_FREQUENCY * 1.5, seed=7)
+
+ readings = _readings(crowded, A4_FREQUENCY)
+
+ assert abs(_median_cents(readings, A4_FREQUENCY)) < CENT_TOLERANCE
+ assert float(np.median([reading.confidence for reading in readings])) < float(
+ np.median([reading.confidence for reading in _readings(alone, A4_FREQUENCY)])
+ )
diff --git a/tests/unit/sampletones_core/formats/__init__.py b/tests/unit/sampletones_core/formats/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/tests/unit/sampletones_core/formats/bitphase/conftest.py b/tests/unit/sampletones_core/formats/bitphase/conftest.py
index ae7d40788..8b8b36ba2 100644
--- a/tests/unit/sampletones_core/formats/bitphase/conftest.py
+++ b/tests/unit/sampletones_core/formats/bitphase/conftest.py
@@ -2,9 +2,11 @@
import numpy as np
-from sampletones_core.constants.enums import GeneratorName
+from sampletones_core.constants.enums import ChannelName
from sampletones_core.exporters.feature import Features
-from sampletones_core.trackers.request import InstrumentExport, SampleExport
+from sampletones_core.exports.request import InstrumentExport, SampleExport
+from sampletones_core.features.envelope import Envelope
+from sampletones_shared.music import Tuning
NES_FREQUENCY: Final[int] = 60
REFERENCE_PITCH: Final[int] = 60
@@ -17,15 +19,15 @@ def build_features(
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)
+ """Builds the envelopes of one channel slice, flat in every dimension left out."""
+ contour = (0,) * len(volume) if arpeggio is None else tuple(arpeggio)
return Features(
initial_pitch=initial_pitch,
- volume=np.array(volume, dtype=int),
- arpeggio=contour,
+ volume=Envelope(items=tuple(volume)),
+ arpeggio=Envelope(items=contour),
pitch=None,
hi_pitch=None,
- duty_cycle=None if duty_cycle is None else np.array(duty_cycle, dtype=int),
+ duty_cycle=None if duty_cycle is None else Envelope(items=tuple(duty_cycle)),
)
@@ -33,17 +35,35 @@ def build_instrument(
name: str,
features: Features,
*,
- generator: GeneratorName = GeneratorName.PULSE1,
- loop: bool = False,
+ channel: ChannelName = ChannelName.PULSE1,
+ loop_point: Optional[int] = None,
) -> InstrumentExport:
return InstrumentExport(
name=name,
- generator=generator,
- features=features,
- loop=loop,
+ channel=channel,
+ features=looping(features, loop_point),
nes_frequency=NES_FREQUENCY,
+ tuning=Tuning(),
)
def build_sample(name: str, *instruments: InstrumentExport) -> SampleExport:
- return SampleExport(name=name, instruments=instruments, nes_frequency=NES_FREQUENCY)
+ return SampleExport(
+ name=name,
+ instruments=instruments,
+ nes_frequency=NES_FREQUENCY,
+ tuning=Tuning(),
+ )
+
+
+def looping(features: Features, loop_point: Optional[int]) -> Features:
+ """The envelopes with every dimension they write circling from ``loop_point``."""
+ if loop_point is None:
+ return features
+
+ circling = features
+ for feature_key, envelope in features.envelopes.items():
+ if envelope.written:
+ circling = circling.with_envelope(feature_key, envelope.model_copy(update={"loop_point": loop_point}))
+
+ return circling
diff --git a/tests/unit/sampletones_core/formats/bitphase/test_btp.py b/tests/unit/sampletones_core/formats/bitphase/test_btp.py
index dc7b3abaa..1c65bdd76 100644
--- a/tests/unit/sampletones_core/formats/bitphase/test_btp.py
+++ b/tests/unit/sampletones_core/formats/bitphase/test_btp.py
@@ -5,7 +5,7 @@
import pytest
-from sampletones_core.constants.enums import GeneratorName
+from sampletones_core.constants.enums import ChannelName
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
@@ -68,7 +68,7 @@ def project_fixture() -> BitphaseProject:
build_instrument(
"Kick (noise)",
build_features(VOLUME_ENVELOPE, duty_cycle=[1, 1, 0, 0]),
- generator=GeneratorName.NOISE,
+ channel=ChannelName.NOISE,
),
)
)
diff --git a/tests/unit/sampletones_core/formats/bitphase/test_builder.py b/tests/unit/sampletones_core/formats/bitphase/test_builder.py
index 7494578f5..424525221 100644
--- a/tests/unit/sampletones_core/formats/bitphase/test_builder.py
+++ b/tests/unit/sampletones_core/formats/bitphase/test_builder.py
@@ -3,7 +3,7 @@
import pytest
-from sampletones_core.constants.enums import GeneratorName
+from sampletones_core.constants.enums import ChannelName
from sampletones_core.constants.general import NUM_PERIODS
from sampletones_core.formats.bitphase.builder import (
PREVIEW_REST_PATTERN_ID,
@@ -62,7 +62,7 @@ def project_fixture() -> BitphaseProject:
build_instrument(
"Kick (noise)",
build_features(VOLUME_ENVELOPE, initial_pitch=NOISE_PERIOD),
- generator=GeneratorName.NOISE,
+ channel=ChannelName.NOISE,
),
)
)
@@ -207,7 +207,7 @@ def test_a_noise_slice_reaches_the_noise_channel(self) -> None:
build_instrument(
"Hat",
build_features(VOLUME_ENVELOPE, arpeggio=[0, 1, 2, 3], initial_pitch=NOISE_PERIOD),
- generator=GeneratorName.NOISE,
+ channel=ChannelName.NOISE,
)
)
row = project.songs[0].patterns[0].channels[int(ChannelIndex.NOISE)].rows[PREVIEW_TRIGGER_ROW]
@@ -222,7 +222,7 @@ def test_a_noise_table_holds_offsets_within_one_period_cycle(self) -> None:
arpeggio=[0, -1, -2, -3],
initial_pitch=NOISE_PERIOD,
),
- generator=GeneratorName.NOISE,
+ channel=ChannelName.NOISE,
)
)
assert all(0 <= offset < NUM_PERIODS for offset in project.tables[0].rows)
diff --git a/tests/unit/sampletones_core/formats/bitphase/test_envelopes.py b/tests/unit/sampletones_core/formats/bitphase/test_envelopes.py
index 414cbc07f..3662685c4 100644
--- a/tests/unit/sampletones_core/formats/bitphase/test_envelopes.py
+++ b/tests/unit/sampletones_core/formats/bitphase/test_envelopes.py
@@ -3,7 +3,7 @@
import pytest
-from sampletones_core.constants.enums import GeneratorName
+from sampletones_core.constants.enums import ChannelName
from sampletones_core.constants.general import NUM_PERIODS
from sampletones_core.formats.bitphase.envelopes import (
ChannelEnvelopes,
@@ -19,22 +19,22 @@
SILENT_VOLUME,
)
-from .conftest import build_features
+from .conftest import build_features, looping
@dataclass
class PulseWidthCase:
- generator: GeneratorName
+ channel: ChannelName
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),
+ PulseWidthCase(channel=ChannelName.PULSE1, duty_cycle=2, pulse_width=2),
+ PulseWidthCase(channel=ChannelName.PULSE2, duty_cycle=3, pulse_width=3),
+ PulseWidthCase(channel=ChannelName.TRIANGLE, duty_cycle=3, pulse_width=FLAT_PULSE_WIDTH),
+ PulseWidthCase(channel=ChannelName.NOISE, duty_cycle=0, pulse_width=NOISE_MODE_LONG),
+ PulseWidthCase(channel=ChannelName.NOISE, duty_cycle=1, pulse_width=NOISE_MODE_SHORT),
]
VOLUME_ENVELOPE: Final[List[int]] = [15, 12, 8, 4, 0]
@@ -45,37 +45,33 @@ 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,
+ ChannelName.PULSE1,
)
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,
+ ChannelName.PULSE1,
)
assert list(envelopes.table_rows) == PITCH_CONTOUR
@pytest.mark.parametrize(
"case",
PULSE_WIDTH_CASES,
- ids=lambda case: f"{case.generator}-{case.duty_cycle}",
+ ids=lambda case: f"{case.channel}-{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,
+ case.channel,
)
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,
+ ChannelName.TRIANGLE,
)
assert {row.pulse_width for row in envelopes.rows} == {FLAT_PULSE_WIDTH}
@@ -83,8 +79,7 @@ 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,
+ ChannelName.NOISE,
)
assert list(envelopes.table_rows) == [(-step) % NUM_PERIODS for step in steps]
@@ -97,33 +92,30 @@ class TestTheDimensionsStayInStep:
@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,
+ looping(build_features(VOLUME_ENVELOPE, arpeggio=PITCH_CONTOUR[:3]), 0 if loop else None),
+ ChannelName.PULSE1,
)
assert len(envelopes.rows) == len(envelopes.table_rows)
- def test_a_looping_slice_takes_the_shortest_dimension(self) -> None:
+ def test_a_looping_slice_stands_at_the_longest_dimension(self) -> None:
+ """Each dimension keeps its own length, so circling costs a slice none of its rows."""
envelopes = features_to_envelopes(
- build_features(VOLUME_ENVELOPE, arpeggio=PITCH_CONTOUR[:2]),
- GeneratorName.PULSE1,
- loop=True,
+ looping(build_features(VOLUME_ENVELOPE, arpeggio=PITCH_CONTOUR[:2]), 0),
+ ChannelName.PULSE1,
)
- assert len(envelopes.rows) == 2
+ assert len(envelopes.rows) == len(VOLUME_ENVELOPE)
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,
+ ChannelName.PULSE1,
)
assert list(envelopes.table_rows) == [0, 2, 2, 2, 2]
def test_a_slice_without_a_contour_holds_its_note(self) -> None:
envelopes = features_to_envelopes(
build_features(VOLUME_ENVELOPE, arpeggio=[]),
- GeneratorName.PULSE1,
- loop=False,
+ ChannelName.PULSE1,
)
assert list(envelopes.table_rows) == [NO_TABLE_OFFSET] * len(VOLUME_ENVELOPE)
@@ -131,17 +123,15 @@ def test_a_slice_without_a_contour_holds_its_note(self) -> None:
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,
+ looping(build_features(VOLUME_ENVELOPE), 0),
+ ChannelName.PULSE1,
)
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,
+ ChannelName.PULSE1,
)
assert envelopes.loop == len(envelopes.rows) - 1
@@ -151,17 +141,15 @@ def test_a_one_shot_rests_in_silence(self) -> None:
"""
envelopes = features_to_envelopes(
build_features(VOLUME_ENVELOPE),
- GeneratorName.PULSE1,
- loop=False,
+ ChannelName.PULSE1,
)
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,
+ looping(build_features(VOLUME_ENVELOPE, arpeggio=PITCH_CONTOUR), 0 if loop else None),
+ ChannelName.PULSE1,
)
assert envelopes.loop < len(envelopes.rows)
assert envelopes.loop < len(envelopes.table_rows)
@@ -175,16 +163,14 @@ class TestASliceThatLeavesItsVolumeToTheChannel:
def test_it_holds_a_full_row_per_frame(self) -> None:
envelopes = features_to_envelopes(
build_features([], arpeggio=PITCH_CONTOUR),
- GeneratorName.PULSE1,
- loop=False,
+ ChannelName.PULSE1,
)
assert [row.volume_or_rate for row in envelopes.rows] == [MAX_VOLUME_OR_RATE] * len(PITCH_CONTOUR)
def test_its_contour_still_moves_the_note(self) -> None:
envelopes = features_to_envelopes(
build_features([], arpeggio=PITCH_CONTOUR),
- GeneratorName.PULSE1,
- loop=False,
+ ChannelName.PULSE1,
)
assert list(envelopes.table_rows) == PITCH_CONTOUR
@@ -192,24 +178,21 @@ def test_its_duty_envelope_still_reaches_the_rows(self) -> None:
duty_cycles = [0, 1, 2, 3]
envelopes = features_to_envelopes(
build_features([], duty_cycle=duty_cycles),
- GeneratorName.PULSE1,
- loop=False,
+ ChannelName.PULSE1,
)
assert [row.pulse_width for row in envelopes.rows] == duty_cycles
def test_a_one_shot_rests_at_the_level_the_channel_holds(self) -> None:
envelopes = features_to_envelopes(
build_features([], arpeggio=PITCH_CONTOUR),
- GeneratorName.PULSE1,
- loop=False,
+ ChannelName.PULSE1,
)
assert envelopes.rows[envelopes.loop].volume_or_rate == MAX_VOLUME_OR_RATE
def test_a_looping_slice_takes_the_length_its_contour_states(self) -> None:
envelopes = features_to_envelopes(
- build_features([], arpeggio=PITCH_CONTOUR),
- GeneratorName.PULSE1,
- loop=True,
+ looping(build_features([], arpeggio=PITCH_CONTOUR), 0),
+ ChannelName.PULSE1,
)
assert len(envelopes.rows) == len(PITCH_CONTOUR)
assert envelopes.loop == LOOP_FROM_START
@@ -222,7 +205,7 @@ class TestAnEmptySlice:
@pytest.fixture(name="envelopes")
def envelopes_fixture(self) -> ChannelEnvelopes:
- return features_to_envelopes(build_features([]), GeneratorName.PULSE1, loop=False)
+ return features_to_envelopes(build_features([]), ChannelName.PULSE1)
def test_it_holds_one_silent_row(self, envelopes: ChannelEnvelopes) -> None:
assert [row.volume_or_rate for row in envelopes.rows] == [SILENT_VOLUME]
diff --git a/tests/unit/sampletones_core/formats/bitphase/test_instrument_document.py b/tests/unit/sampletones_core/formats/bitphase/test_instrument_document.py
new file mode 100644
index 000000000..61dbfd66b
--- /dev/null
+++ b/tests/unit/sampletones_core/formats/bitphase/test_instrument_document.py
@@ -0,0 +1,79 @@
+from typing import Final, Tuple
+
+from sampletones_core.constants.enums import ChannelName
+from sampletones_core.features.envelope import Envelope
+from sampletones_core.formats.bitphase.builder import project_to_bitphase
+from sampletones_core.formats.bitphase.specification.instruments import LOOP_FROM_START
+from sampletones_core.project.patterns.row import Row
+from sampletones_core.project.project import Project
+from sampletones_core.project.voices.envelopes import InstrumentEnvelopes
+from sampletones_core.project.voices.instrument import Instrument
+from sampletones_core.project.voices.note_on import NoteOn
+
+ROWS_PER_PATTERN: Final[int] = 4
+VOLUME: Final[Tuple[int, ...]] = (15, 12, 9)
+ARPEGGIO: Final[Tuple[int, ...]] = (0, 4, 7)
+
+
+def _project(*channels: ChannelName, loop_point: int | None = None) -> Tuple[Project, Instrument]:
+ instrument = Instrument(
+ name="Lead",
+ envelopes=InstrumentEnvelopes(
+ volume=Envelope(items=VOLUME, loop_point=loop_point),
+ arpeggio=Envelope(items=ARPEGGIO, loop_point=loop_point),
+ duty_cycle=Envelope(items=(1,)),
+ ),
+ )
+ project = Project.create(title="Demo", rows_per_pattern=ROWS_PER_PATTERN)
+ project.voices.append(instrument)
+ for channel in channels:
+ pattern = project.song[channel].ensure_pattern(0, ROWS_PER_PATTERN)
+ pattern.rows[0] = Row(command=NoteOn(voice_id=instrument.id))
+ project.song.set_order_entry(0, channel, 0)
+
+ return project, instrument
+
+
+class TestAnInstrumentReachesTheDocument:
+ def test_each_channel_it_sounds_on_takes_an_instrument_of_its_own(self) -> None:
+ """Bitphase bakes registers per tick, so a channel's rows carry that channel's reading."""
+ project, _ = _project(ChannelName.PULSE1, ChannelName.NOISE)
+
+ document = project_to_bitphase(project)
+
+ assert len(document.instruments) == len(ChannelName.items())
+
+ def test_every_instrument_runs_the_ticks_its_envelopes_describe(self) -> None:
+ project, _ = _project(ChannelName.PULSE1)
+
+ document = project_to_bitphase(project)
+
+ assert all(len(instrument.rows) == len(VOLUME) for instrument in document.instruments)
+
+ def test_a_looping_instrument_returns_to_its_loop_point(self) -> None:
+ project, _ = _project(ChannelName.PULSE1, loop_point=1)
+
+ document = project_to_bitphase(project)
+
+ assert all(instrument.loop == 1 for instrument in document.instruments)
+
+ def test_a_one_shot_rests_on_its_final_row(self) -> None:
+ project, _ = _project(ChannelName.PULSE1)
+
+ document = project_to_bitphase(project)
+
+ assert all(instrument.loop == len(instrument.rows) - 1 for instrument in document.instruments)
+
+ def test_the_table_carries_the_instruments_contour(self) -> None:
+ project, _ = _project(ChannelName.PULSE1)
+
+ document = project_to_bitphase(project)
+
+ assert any(tuple(table.rows) == ARPEGGIO for table in document.tables)
+
+ def test_the_document_is_written_without_a_loop_past_its_rows(self) -> None:
+ project, _ = _project(ChannelName.PULSE1, loop_point=LOOP_FROM_START)
+
+ document = project_to_bitphase(project)
+
+ assert all(instrument.loop < len(instrument.rows) for instrument in document.instruments)
diff --git a/tests/unit/sampletones_core/formats/bitphase/test_preset.py b/tests/unit/sampletones_core/formats/bitphase/test_preset.py
index ed3b8e11f..d99ed9729 100644
--- a/tests/unit/sampletones_core/formats/bitphase/test_preset.py
+++ b/tests/unit/sampletones_core/formats/bitphase/test_preset.py
@@ -4,7 +4,7 @@
import pytest
-from sampletones_core.constants.enums import GeneratorName
+from sampletones_core.constants.enums import ChannelName
from sampletones_core.formats.bitphase.model.instrument import BitphaseInstrumentPreset
from sampletones_core.formats.bitphase.notes import pitch_to_note_index
from sampletones_core.formats.bitphase.preset import (
@@ -55,7 +55,7 @@ def test_a_one_shot_rests_on_its_last_row(self, preset: BitphaseInstrumentPreset
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),
+ build_instrument("Pad", build_features(VOLUME_ENVELOPE), loop_point=0),
)
assert preset.loop == LOOP_FROM_START
@@ -92,7 +92,7 @@ def test_a_noise_slice_takes_its_period_from_the_note(self) -> None:
build_instrument(
"Hat",
build_features(VOLUME_ENVELOPE, arpeggio=[0, 1, 2, 3], initial_pitch=NOISE_PERIOD),
- generator=GeneratorName.NOISE,
+ channel=ChannelName.NOISE,
),
)
assert {row.tone_add for row in preset.rows} == {NO_TONE_OFFSET}
diff --git a/tests/unit/sampletones_core/formats/bitphase/test_project_builder.py b/tests/unit/sampletones_core/formats/bitphase/test_project_builder.py
index c1ff20a1d..ec285f6ac 100644
--- a/tests/unit/sampletones_core/formats/bitphase/test_project_builder.py
+++ b/tests/unit/sampletones_core/formats/bitphase/test_project_builder.py
@@ -5,7 +5,7 @@
import pytest
from sampletones_core.configs import Config
-from sampletones_core.constants.enums import GeneratorName
+from sampletones_core.constants.enums import ChannelName
from sampletones_core.constants.general import SILENT_VOLUME
from sampletones_core.formats.bitphase.builder import project_to_bitphase
from sampletones_core.formats.bitphase.model.pattern import BitphaseRow, EffectCell
@@ -33,17 +33,18 @@
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.project.voices.note_off import NoteOff
+from sampletones_core.project.voices.note_on import NoteOn
+from sampletones_core.project.voices.sample import Sample
from sampletones_core.reconstructions import Reconstruction
from sampletones_core.structures import IdentifiedCollection
+from tests.suite.stems import single_entry_stems_data
RECONSTRUCTION_LENGTH: Final[int] = 4
ROWS_PER_PATTERN: Final[int] = 8
@@ -61,16 +62,17 @@
def build_reconstruction(
- instructions: Mapping[GeneratorName, Sequence[Instruction]],
+ instructions: Mapping[ChannelName, Sequence[Instruction]],
) -> Reconstruction:
- approximations = {generator: np.zeros(RECONSTRUCTION_LENGTH, dtype=np.float32) for generator in instructions}
+ approximations = {channel: np.zeros(RECONSTRUCTION_LENGTH, dtype=np.float32) for channel 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"),
+ audio_filepath=(Path("/dev/null"),),
+ stems_data=single_entry_stems_data(list(Config().generation.channels), instructions),
)
@@ -78,7 +80,7 @@ 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}),
+ reconstruction=build_reconstruction({ChannelName.PULSE1: instructions}),
)
@@ -86,7 +88,7 @@ def triangle_sample(name: str, pitch: int) -> Sample:
instructions = [TriangleInstruction(on=True, pitch=pitch)]
return Sample(
name=name,
- reconstruction=build_reconstruction({GeneratorName.TRIANGLE: instructions}),
+ reconstruction=build_reconstruction({ChannelName.TRIANGLE: instructions}),
)
@@ -102,42 +104,42 @@ def bass_fixture() -> Sample:
@pytest.fixture(name="source")
def source_fixture(lead: Sample, bass: Sample) -> Project:
- samples: IdentifiedCollection[Sample] = IdentifiedCollection()
+ voices: IdentifiedCollection[Sample] = IdentifiedCollection()
for sample in (lead, bass):
- samples.append(sample)
+ voices.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),
+ command=NoteOn(voice_id=lead.id),
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),
+ command=NoteOn(voice_id=lead.id),
transpose=TRANSPOSE,
)
pulse_rows[SILENCED_ROW] = Row(volume=SILENT_VOLUME)
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),
+ command=NoteOn(voice_id=bass.id),
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={}),
+ ChannelName.PULSE1: Channel(name=ChannelName.PULSE1, patterns={0: Pattern(rows=pulse_rows)}),
+ ChannelName.PULSE2: Channel(name=ChannelName.PULSE2, patterns={}),
+ ChannelName.TRIANGLE: Channel(name=ChannelName.TRIANGLE, patterns={0: Pattern(rows=triangle_rows)}),
+ ChannelName.NOISE: Channel(name=ChannelName.NOISE, patterns={}),
}
- order: List[Dict[GeneratorName, Optional[int]]] = [
- {GeneratorName.PULSE1: 0, GeneratorName.TRIANGLE: 0},
- {GeneratorName.PULSE1: None, GeneratorName.TRIANGLE: 0},
+ order: List[Dict[ChannelName, Optional[int]]] = [
+ {ChannelName.PULSE1: 0, ChannelName.TRIANGLE: 0},
+ {ChannelName.PULSE1: None, ChannelName.TRIANGLE: 0},
]
project = Project.create(title="Demo", author="Tester", settings=ProjectSettings())
- project.samples = samples
+ project.voices = voices
project.song = Song(rows_per_pattern=ROWS_PER_PATTERN, order=order, channels=channels)
return project
@@ -318,12 +320,12 @@ def test_the_sounding_channels_keep_their_effect_columns(self, grooved_document:
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,
+ rows[TRIGGER_ROW] = Row(command=NoteOn(voice_id=lead.id))
+ source.song.channels[ChannelName.PULSE2] = Channel(
+ name=ChannelName.PULSE2,
patterns={0: Pattern(rows=rows)},
)
- source.song.order[0][GeneratorName.PULSE2] = 0
+ source.song.order[0][ChannelName.PULSE2] = 0
- with pytest.raises(ValueError, match="has no instrument"):
+ with pytest.raises(ValueError, match="with 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
index 7763628aa..13583ba15 100644
--- a/tests/unit/sampletones_core/formats/bitphase/test_tuning.py
+++ b/tests/unit/sampletones_core/formats/bitphase/test_tuning.py
@@ -52,7 +52,7 @@ def ntsc_table_fixture() -> Tuple[int, ...]:
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.
+ the reconstruction it came from. These numbers come from Bitphase's own channel.
"""
def test_the_ntsc_table_equals_the_one_bitphase_derives(
diff --git a/tests/unit/sampletones_core/formats/famitracker/conftest.py b/tests/unit/sampletones_core/formats/famitracker/conftest.py
index aaf858000..9c1ad563c 100644
--- a/tests/unit/sampletones_core/formats/famitracker/conftest.py
+++ b/tests/unit/sampletones_core/formats/famitracker/conftest.py
@@ -6,49 +6,50 @@
import pytest
from sampletones_core.configs import Config
-from sampletones_core.constants.enums import GeneratorName
+from sampletones_core.constants.enums import ChannelName
from sampletones_core.instructions.implementation.noise import NoiseInstruction
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.project.voices.note_off import NoteOff
+from sampletones_core.project.voices.note_on import NoteOn
+from sampletones_core.project.voices.sample import Sample
from sampletones_core.reconstructions import Reconstruction
from sampletones_core.structures import IdentifiedCollection
+from tests.suite.stems import single_entry_stems_data
RECONSTRUCTION_LENGTH = 8
def build_reconstruction(
- instructions: Mapping[GeneratorName, Sequence[Instruction]],
+ instructions: Mapping[ChannelName, Sequence[Instruction]],
) -> Reconstruction:
- approximations = {generator: np.zeros(RECONSTRUCTION_LENGTH, dtype=np.float32) for generator in instructions}
+ approximations = {channel: np.zeros(RECONSTRUCTION_LENGTH, dtype=np.float32) for channel 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"),
+ audio_filepath=(Path("/dev/null"),),
+ stems_data=single_entry_stems_data(list(Config().generation.channels), instructions),
)
-def pulse_sample(name: str, pitch: int, *, loop: bool = False) -> Sample:
+def pulse_sample(name: str, pitch: int) -> Sample:
instructions = [
PulseInstruction(on=True, pitch=pitch, volume=15, duty_cycle=0),
PulseInstruction(on=True, pitch=pitch, volume=8, duty_cycle=0),
]
return Sample(
name=name,
- reconstruction=build_reconstruction({GeneratorName.PULSE1: instructions}),
- loop=loop,
+ reconstruction=build_reconstruction({ChannelName.PULSE1: instructions}),
)
@@ -56,14 +57,14 @@ def noise_sample(name: str, period: int) -> Sample:
instructions = [NoiseInstruction(on=True, period=period, volume=15, short=False)]
return Sample(
name=name,
- reconstruction=build_reconstruction({GeneratorName.NOISE: instructions}),
+ reconstruction=build_reconstruction({ChannelName.NOISE: instructions}),
)
def dual_generator_sample(name: str, pulse_pitch: int, triangle_pitch: int) -> Sample:
- instructions: Mapping[GeneratorName, Sequence[Instruction]] = {
- GeneratorName.PULSE1: [PulseInstruction(on=True, pitch=pulse_pitch, volume=15, duty_cycle=0)],
- GeneratorName.TRIANGLE: [TriangleInstruction(on=True, pitch=triangle_pitch)],
+ instructions: Mapping[ChannelName, Sequence[Instruction]] = {
+ ChannelName.PULSE1: [PulseInstruction(on=True, pitch=pulse_pitch, volume=15, duty_cycle=0)],
+ ChannelName.TRIANGLE: [TriangleInstruction(on=True, pitch=triangle_pitch)],
}
return Sample(name=name, reconstruction=build_reconstruction(instructions))
@@ -80,17 +81,17 @@ class ProjectFixture:
@pytest.fixture
def project_fixture() -> ProjectFixture:
lead = pulse_sample("lead", pitch=60)
- pad = pulse_sample("pad", pitch=48, loop=True)
+ pad = pulse_sample("pad", pitch=48)
drum = noise_sample("drum", period=4)
bell = dual_generator_sample("bell", pulse_pitch=72, triangle_pitch=36)
- samples: IdentifiedCollection[Sample] = IdentifiedCollection()
+ voices: IdentifiedCollection[Sample] = IdentifiedCollection()
for sample in (lead, pad, drum, bell):
- samples.append(sample)
+ voices.append(sample)
pulse_rows: List[Row] = [Row() for _ in range(8)]
pulse_rows[0] = Row(
- command=Instrument(sample_id=lead.id, generator_name=GeneratorName.PULSE1),
+ command=NoteOn(voice_id=lead.id),
transpose=0,
volume=10,
)
@@ -99,36 +100,36 @@ def project_fixture() -> ProjectFixture:
noise_rows: List[Row] = [Row() for _ in range(8)]
noise_rows[0] = Row(
- command=Instrument(sample_id=drum.id, generator_name=GeneratorName.NOISE),
+ command=NoteOn(voice_id=drum.id),
transpose=0,
volume=15,
)
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={}),
- GeneratorName.NOISE: Channel(generator=GeneratorName.NOISE, patterns={0: Pattern(rows=noise_rows)}),
+ ChannelName.PULSE1: Channel(name=ChannelName.PULSE1, patterns={0: Pattern(rows=pulse_rows)}),
+ ChannelName.PULSE2: Channel(name=ChannelName.PULSE2, patterns={}),
+ ChannelName.TRIANGLE: Channel(name=ChannelName.TRIANGLE, patterns={}),
+ ChannelName.NOISE: Channel(name=ChannelName.NOISE, patterns={0: Pattern(rows=noise_rows)}),
}
order = [
{
- GeneratorName.PULSE1: 0,
- GeneratorName.PULSE2: None,
- GeneratorName.TRIANGLE: None,
- GeneratorName.NOISE: 0,
+ ChannelName.PULSE1: 0,
+ ChannelName.PULSE2: None,
+ ChannelName.TRIANGLE: None,
+ ChannelName.NOISE: 0,
},
{
- GeneratorName.PULSE1: None,
- GeneratorName.PULSE2: None,
- GeneratorName.TRIANGLE: None,
- GeneratorName.NOISE: None,
+ ChannelName.PULSE1: None,
+ ChannelName.PULSE2: None,
+ ChannelName.TRIANGLE: None,
+ ChannelName.NOISE: None,
},
]
song = Song(rows_per_pattern=8, order=order, channels=channels)
project = Project.create(title="Demo", author="Tester", settings=ProjectSettings())
project.info.comment = "a comment"
- project.samples = samples
+ project.voices = voices
project.song = song
return ProjectFixture(project=project, lead=lead, pad=pad, drum=drum, bell=bell)
diff --git a/tests/unit/sampletones_core/formats/famitracker/sequences/test_features.py b/tests/unit/sampletones_core/formats/famitracker/sequences/test_features.py
index 9f625a53a..7298b0551 100644
--- a/tests/unit/sampletones_core/formats/famitracker/sequences/test_features.py
+++ b/tests/unit/sampletones_core/formats/famitracker/sequences/test_features.py
@@ -1,7 +1,13 @@
-import numpy as np
+from typing import Final, Optional, Sequence
+from sampletones_core.constants.enums import FeatureKey
+from sampletones_core.constants.general import MAX_VOLUME, SILENT_VOLUME
+from sampletones_core.exporters.feature import Features
+from sampletones_core.features.envelope import Envelope
from sampletones_core.formats.famitracker.sequences.features import (
features_to_instrument_sequences,
+ features_truncation,
+ is_shortened,
)
from sampletones_core.formats.famitracker.specification.sequences import (
LOOP_FROM_START,
@@ -10,187 +16,243 @@
SequenceKind,
)
+REFERENCE_PITCH: Final[int] = 60
+RELEASE: Final[int] = SILENT_VOLUME
-class TestFeaturesToInstrumentSequences:
- def test_all_five_kinds_present(self) -> None:
+
+def sounding(length: int) -> Sequence[int]:
+ """A volume dimension of ``length`` items that never falls silent."""
+ return [MAX_VOLUME - index % MAX_VOLUME for index in range(length)]
+
+
+def released(length: int) -> Sequence[int]:
+ """A volume dimension of ``length`` items whose last one releases the note."""
+ return list(sounding(length - 1)) + [RELEASE]
+
+
+def envelope(items: Sequence[int], loop_point: Optional[int] = None) -> Envelope[int]:
+ return Envelope[int](items=tuple(items), loop_point=loop_point if items else None)
+
+
+def build(
+ volume: Sequence[int],
+ arpeggio: Sequence[int],
+ *,
+ pitch: Optional[Sequence[int]] = None,
+ duty_cycle: Optional[Sequence[int]] = None,
+ loop_point: Optional[int] = None,
+) -> Features:
+ """One channel slice's envelopes, each dimension carrying the point it repeats from."""
+ return Features(
+ initial_pitch=REFERENCE_PITCH,
+ volume=envelope(volume, loop_point),
+ arpeggio=envelope(arpeggio, loop_point),
+ pitch=None if pitch is None else envelope(pitch, loop_point),
+ hi_pitch=None,
+ duty_cycle=None if duty_cycle is None else envelope(duty_cycle, loop_point),
+ )
+
+
+class TestTheArpeggioThatPinsABend:
+ """A bend travels with an arpeggio, because that is what makes each item an offset.
+
+ FamiTracker walks an instrument's sequences in slot order, and an absolute arpeggio reloads
+ the period from the note before a bend adds to it. A bend the arpeggio covers therefore sounds
+ in the tracker as the per-tick offset this project writes it as.
+ """
+
+ def test_a_bend_without_an_arpeggio_gains_a_repeating_one(self) -> None:
+ arpeggio = features_to_instrument_sequences(build([15, 0], [], pitch=[3, -3]))[SequenceKind.ARPEGGIO]
+
+ assert arpeggio.enabled is True
+ assert arpeggio.items == (0,)
+ assert arpeggio.loop_point == LOOP_FROM_START
+
+ def test_an_arpeggio_shorter_than_the_bend_reaches_its_length(self) -> None:
+ arpeggio = features_to_instrument_sequences(build([15, 0], [4, 7], pitch=[1, 2, 3, 4]))[SequenceKind.ARPEGGIO]
+
+ assert arpeggio.items == (4, 7, 7, 7)
+
+ def test_an_arpeggio_that_repeats_is_left_as_it_stands(self) -> None:
sequences = features_to_instrument_sequences(
- volume=np.array([15, 0]),
- arpeggio=np.array([0]),
- pitch=None,
- hi_pitch=None,
- duty_cycle=None,
- loop=False,
+ build([15, 0], [0, 5], pitch=[1, 2, 3, 4], loop_point=LOOP_FROM_START)
)
+
+ assert sequences[SequenceKind.ARPEGGIO].items == (0, 5)
+ assert sequences[SequenceKind.ARPEGGIO].loop_point == LOOP_FROM_START
+
+ def test_an_arpeggio_already_covering_the_bend_is_left_as_it_stands(self) -> None:
+ arpeggio = features_to_instrument_sequences(build([15, 0], [4, 7, 9], pitch=[1, 2]))[SequenceKind.ARPEGGIO]
+
+ assert arpeggio.items == (4, 7, 9)
+
+ def test_an_instrument_writing_no_bend_gains_no_arpeggio(self) -> None:
+ arpeggio = features_to_instrument_sequences(build([15, 0], []))[SequenceKind.ARPEGGIO]
+
+ assert arpeggio.enabled is False
+
+
+class TestFeaturesToInstrumentSequences:
+ def test_all_five_kinds_present(self) -> None:
+ sequences = features_to_instrument_sequences(build([15, 0], [0]))
assert set(sequences) == set(SequenceKind)
def test_populated_dimension_is_enabled_with_items(self) -> None:
- sequences = features_to_instrument_sequences(
- volume=np.array([15, 12, 0]),
- arpeggio=np.array([], dtype=int),
- pitch=None,
- hi_pitch=None,
- duty_cycle=None,
- loop=False,
- )
- volume = sequences[SequenceKind.VOLUME]
+ volume = features_to_instrument_sequences(build([15, 12, 0], []))[SequenceKind.VOLUME]
assert volume.enabled is True
assert volume.items == (15, 12, 0)
def test_missing_dimension_is_disabled(self) -> None:
- sequences = features_to_instrument_sequences(
- volume=np.array([15, 0]),
- arpeggio=np.array([], dtype=int),
- pitch=None,
- hi_pitch=None,
- duty_cycle=None,
- loop=False,
- )
+ sequences = features_to_instrument_sequences(build([15, 0], []))
assert sequences[SequenceKind.PITCH].enabled is False
assert sequences[SequenceKind.PITCH].items == ()
assert sequences[SequenceKind.ARPEGGIO].enabled is False
def test_items_are_python_ints(self) -> None:
- sequences = features_to_instrument_sequences(
- volume=np.array([15, 8], dtype=np.int8),
- arpeggio=np.array([-3], dtype=np.int8),
- pitch=None,
- hi_pitch=None,
- duty_cycle=None,
- loop=False,
- )
+ sequences = features_to_instrument_sequences(build([15, 8], [-3]))
assert all(isinstance(item, int) for item in sequences[SequenceKind.VOLUME].items)
assert all(isinstance(item, int) for item in sequences[SequenceKind.ARPEGGIO].items)
- def test_loop_sets_loop_point_on_populated_sequences(self) -> None:
- sequences = features_to_instrument_sequences(
- volume=np.array([15, 10]),
- arpeggio=np.array([0, 2]),
- pitch=None,
- hi_pitch=None,
- duty_cycle=None,
- loop=True,
- )
+
+class TestTheLoopPointEachSequenceCarries:
+ def test_a_dimension_carries_the_point_it_states(self) -> None:
+ sequences = features_to_instrument_sequences(build([15, 10], [0, 2], loop_point=0))
assert sequences[SequenceKind.VOLUME].loop_point == LOOP_FROM_START
assert sequences[SequenceKind.ARPEGGIO].loop_point == LOOP_FROM_START
- def test_loop_leaves_empty_sequences_unlooped(self) -> None:
- sequences = features_to_instrument_sequences(
- volume=np.array([15, 0]),
- arpeggio=np.array([], dtype=int),
+ def test_each_dimension_repeats_from_its_own_item(self) -> None:
+ """A tracker advances every sequence on a counter of its own, so the points stand apart."""
+ features = Features(
+ initial_pitch=REFERENCE_PITCH,
+ volume=envelope([15, 12, 9, 0], 2),
+ arpeggio=envelope([0, 2, 4], 0),
pitch=None,
hi_pitch=None,
- duty_cycle=None,
- loop=True,
+ duty_cycle=envelope([1, 1]),
)
+
+ sequences = features_to_instrument_sequences(features)
+
+ assert sequences[SequenceKind.VOLUME].loop_point == 2
+ assert sequences[SequenceKind.ARPEGGIO].loop_point == 0
+ assert sequences[SequenceKind.DUTY].loop_point == NO_LOOP_POINT
+
+ def test_a_dimension_the_instrument_leaves_out_states_no_point(self) -> None:
+ sequences = features_to_instrument_sequences(build([15, 0], [], loop_point=0))
assert sequences[SequenceKind.PITCH].loop_point == NO_LOOP_POINT
- def test_no_loop_leaves_loop_point_disabled(self) -> None:
- sequences = features_to_instrument_sequences(
- volume=np.array([15, 10]),
- arpeggio=np.array([0]),
- pitch=None,
- hi_pitch=None,
- duty_cycle=None,
- loop=False,
- )
+ def test_a_dimension_that_halts_states_no_point(self) -> None:
+ sequences = features_to_instrument_sequences(build([15, 10], [0]))
assert sequences[SequenceKind.VOLUME].loop_point == NO_LOOP_POINT
class TestSequenceLengths:
- def test_loop_drops_the_trailing_note_off_volume_item(self) -> None:
- sequences = features_to_instrument_sequences(
- volume=np.array([15, 12, 9, 0]),
- arpeggio=np.array([0, 2, 4]),
- pitch=None,
- hi_pitch=None,
- duty_cycle=np.array([1, 1, 2]),
- loop=True,
- )
- assert sequences[SequenceKind.VOLUME].items == (15, 12, 9)
- assert sequences[SequenceKind.ARPEGGIO].items == (0, 2, 4)
- assert sequences[SequenceKind.DUTY].items == (1, 1, 2)
-
- def test_one_shot_carries_each_dimension_as_written(self) -> None:
+ def test_every_dimension_stands_at_the_length_it_was_written(self) -> None:
"""A halted sequence holds its final value, so a shorter dimension governs the rest itself."""
- sequences = features_to_instrument_sequences(
- volume=np.array([15, 12, 9, 0]),
- arpeggio=np.array([0, 2, 4]),
- pitch=None,
- hi_pitch=None,
- duty_cycle=np.array([1]),
- loop=False,
- )
+ sequences = features_to_instrument_sequences(build([15, 12, 9, 0], [0, 2, 4], duty_cycle=[1]))
assert sequences[SequenceKind.VOLUME].items == (15, 12, 9, 0)
assert sequences[SequenceKind.ARPEGGIO].items == (0, 2, 4)
assert sequences[SequenceKind.DUTY].items == (1,)
- def test_a_loop_brings_every_populated_dimension_to_one_length(self) -> None:
+ def test_circling_costs_a_dimension_none_of_its_items(self) -> None:
+ """Each dimension repeats on its own period, so a loop leaves every length as written."""
sequences = features_to_instrument_sequences(
- volume=np.array([15, 12, 9, 0]),
- arpeggio=np.array([0, 2, 4]),
- pitch=np.array([0, 1]),
- hi_pitch=None,
- duty_cycle=np.array([1, 1, 2]),
- loop=True,
+ build([15, 12, 9, 0], [0, 2, 4], duty_cycle=[1, 1, 2], loop_point=0)
)
- lengths = {len(sequence.items) for sequence in sequences.values() if sequence.enabled}
- assert lengths == {2}
+ assert sequences[SequenceKind.VOLUME].items == (15, 12, 9, 0)
+ assert sequences[SequenceKind.ARPEGGIO].items == (0, 2, 4)
+ assert sequences[SequenceKind.DUTY].items == (1, 1, 2)
def test_disabled_dimensions_stay_empty(self) -> None:
- sequences = features_to_instrument_sequences(
- volume=np.array([15, 12, 0]),
- arpeggio=np.array([], dtype=int),
- pitch=None,
- hi_pitch=None,
- duty_cycle=None,
- loop=False,
- )
+ sequences = features_to_instrument_sequences(build([15, 12, 0], []))
assert sequences[SequenceKind.ARPEGGIO].items == ()
assert sequences[SequenceKind.PITCH].items == ()
def test_an_empty_envelope_differs_from_one_holding_a_single_zero(self) -> None:
"""An empty dimension leaves its sequence disabled; a single zero is a value the instrument sets."""
- cleared = features_to_instrument_sequences(
- volume=np.array([15, 0]),
- arpeggio=np.array([], dtype=int),
- pitch=None,
- hi_pitch=None,
- duty_cycle=None,
- loop=False,
- )
- zeroed = features_to_instrument_sequences(
- volume=np.array([15, 0]),
- arpeggio=np.array([0]),
- pitch=None,
- hi_pitch=None,
- duty_cycle=None,
- loop=False,
- )
+ cleared = features_to_instrument_sequences(build([15, 0], []))
+ zeroed = features_to_instrument_sequences(build([15, 0], [0]))
+
assert cleared[SequenceKind.ARPEGGIO].enabled is False
assert zeroed[SequenceKind.ARPEGGIO].enabled is True
assert zeroed[SequenceKind.ARPEGGIO].items == (0,)
def test_all_dimensions_empty_stays_empty(self) -> None:
- sequences = features_to_instrument_sequences(
- volume=np.array([], dtype=int),
- arpeggio=np.array([], dtype=int),
- pitch=None,
- hi_pitch=None,
- duty_cycle=None,
- loop=True,
- )
+ sequences = features_to_instrument_sequences(build([], [], loop_point=0))
assert all(not sequence.enabled for sequence in sequences.values())
def test_an_over_long_envelope_builds_sequences_famitracker_accepts(self) -> None:
length = MAX_SEQUENCE_ITEMS + 48
- sequences = features_to_instrument_sequences(
- volume=np.arange(length) % 16,
- arpeggio=np.zeros(length, dtype=int),
- pitch=None,
- hi_pitch=None,
- duty_cycle=None,
- loop=False,
- )
+ sequences = features_to_instrument_sequences(build([index % 16 for index in range(length)], [0] * length))
assert all(len(sequence.items) <= MAX_SEQUENCE_ITEMS for sequence in sequences.values())
+
+
+class TestTheReleaseAnExportKeeps:
+ """A volume dimension ending at silence is what releases a note, so the file keeps that item.
+
+ FamiTracker halts a sequence on its last item and holds the value there for as long as the
+ note sounds, so a shortened volume dimension that dropped its silence would sound forever.
+ """
+
+ def test_a_released_dimension_at_the_limit_is_written_whole(self) -> None:
+ sequences = features_to_instrument_sequences(build(released(MAX_SEQUENCE_ITEMS), []))
+ volume = sequences[SequenceKind.VOLUME]
+ assert len(volume.items) == MAX_SEQUENCE_ITEMS
+ assert volume.items[-1] == RELEASE
+
+ def test_a_released_dimension_past_the_limit_still_ends_at_its_release(self) -> None:
+ sequences = features_to_instrument_sequences(build(released(MAX_SEQUENCE_ITEMS + 1), []))
+ volume = sequences[SequenceKind.VOLUME]
+ assert len(volume.items) == MAX_SEQUENCE_ITEMS
+ assert volume.items[-1] == RELEASE
+
+ def test_the_release_displaces_the_last_item_that_would_not_fit(self) -> None:
+ source = released(MAX_SEQUENCE_ITEMS + 1)
+ volume = features_to_instrument_sequences(build(source, []))[SequenceKind.VOLUME]
+ assert volume.items == tuple(source[: MAX_SEQUENCE_ITEMS - 1]) + (RELEASE,)
+
+ def test_a_dimension_that_goes_on_sounding_keeps_its_opening_items(self) -> None:
+ source = sounding(MAX_SEQUENCE_ITEMS + 8)
+ volume = features_to_instrument_sequences(build(source, []))[SequenceKind.VOLUME]
+ assert volume.items == tuple(source[:MAX_SEQUENCE_ITEMS])
+
+ def test_a_circling_dimension_reads_its_final_silence_as_part_of_the_cycle(self) -> None:
+ """A dimension repeating from a point never halts, so its last item releases nothing."""
+ source = released(MAX_SEQUENCE_ITEMS + 1)
+ volume = features_to_instrument_sequences(build(source, [], loop_point=0))[SequenceKind.VOLUME]
+ assert volume.items == tuple(source[:MAX_SEQUENCE_ITEMS])
+
+ def test_a_dimension_other_than_volume_keeps_its_opening_items(self) -> None:
+ """Only a volume dimension releases a note; the rest are read at whatever they last stated."""
+ source = [index % 8 for index in range(MAX_SEQUENCE_ITEMS + 8)]
+ source[-1] = 0
+ arpeggio = features_to_instrument_sequences(build(released(4), source))[SequenceKind.ARPEGGIO]
+ assert arpeggio.items == tuple(source[:MAX_SEQUENCE_ITEMS])
+
+
+class TestWhetherAnExportShortensADimension:
+ def test_a_dimension_within_the_limit_is_written_whole(self) -> None:
+ assert is_shortened(FeatureKey.VOLUME, envelope(released(MAX_SEQUENCE_ITEMS))) is False
+
+ def test_a_dimension_past_the_limit_is_shortened(self) -> None:
+ assert is_shortened(FeatureKey.VOLUME, envelope(released(MAX_SEQUENCE_ITEMS + 1))) is True
+
+ def test_keeping_the_release_still_counts_as_shortening(self) -> None:
+ """The release survives, so one sounding item is what the file leaves out."""
+ source = envelope(released(MAX_SEQUENCE_ITEMS + 1))
+ assert is_shortened(FeatureKey.VOLUME, source) is True
+
+
+class TestWhatAnExportReportsLeavingOut:
+ def test_features_within_the_limit_report_nothing(self) -> None:
+ assert features_truncation(build(released(MAX_SEQUENCE_ITEMS), [0])) is None
+
+ def test_features_past_the_limit_report_both_counts(self) -> None:
+ source_frames = MAX_SEQUENCE_ITEMS + 48
+ truncation = features_truncation(build(released(source_frames), []))
+ assert truncation is not None
+ assert truncation.source_frames == source_frames
+ assert truncation.frames == MAX_SEQUENCE_ITEMS
+ assert truncation.instruments == 1
diff --git a/tests/unit/sampletones_core/formats/famitracker/test_binary.py b/tests/unit/sampletones_core/formats/famitracker/test_binary.py
index b07743490..b6bf6549e 100644
--- a/tests/unit/sampletones_core/formats/famitracker/test_binary.py
+++ b/tests/unit/sampletones_core/formats/famitracker/test_binary.py
@@ -1,90 +1,23 @@
import struct
-from dataclasses import dataclass
-from typing import List
import pytest
-from sampletones_core.formats.famitracker.binary import BinaryWriter
+from sampletones_core.formats.famitracker.binary import FamiTrackerWriter
from sampletones_core.formats.famitracker.specification.blocks import (
BLOCK_NAME_LENGTH,
Block,
)
-@dataclass
-class IntegerCase:
- method: str
- format: str
- value: int
-
-
-INTEGER_CASES: List[IntegerCase] = [
- IntegerCase("write_uint8", " None:
- writer = BinaryWriter()
- getattr(writer, case.method)(case.value)
- (unpacked,) = struct.unpack(case.format, writer.data)
- assert unpacked == case.value
-
- def test_writes_are_little_endian(self) -> None:
- writer = BinaryWriter()
- writer.write_uint32(0x0440)
- assert writer.data == b"\x40\x04\x00\x00"
-
- def test_length_tracks_written_bytes(self) -> None:
- writer = BinaryWriter()
- writer.write_uint8(1)
- writer.write_uint32(2)
- assert len(writer) == 5
-
-
-class TestStringPrimitives:
- def test_fixed_string_pads_with_nul(self) -> None:
- writer = BinaryWriter()
- writer.write_fixed_string("abc", 8)
- assert writer.data == b"abc\x00\x00\x00\x00\x00"
- assert len(writer) == 8
-
- def test_fixed_string_truncates_to_length(self) -> None:
- writer = BinaryWriter()
- writer.write_fixed_string("abcdefgh", 4)
- assert writer.data == b"abcd"
-
- def test_counted_string_prefixes_length(self) -> None:
- writer = BinaryWriter()
- writer.write_counted_string("hi")
- length = struct.unpack_from(" None:
- writer = BinaryWriter()
- writer.write_terminated_string("note")
- assert writer.data == b"note\x00"
-
-
class TestBlockFraming:
def test_block_name_padded_to_fixed_length(self) -> None:
- writer = BinaryWriter()
+ writer = FamiTrackerWriter()
with writer.block(Block("PARAMS", 6)):
pass
assert writer.data[:BLOCK_NAME_LENGTH] == b"PARAMS".ljust(BLOCK_NAME_LENGTH, b"\x00")
def test_block_header_carries_version_and_size(self) -> None:
- writer = BinaryWriter()
+ writer = FamiTrackerWriter()
with writer.block(Block("INFO", 1)) as body:
body.write_uint8(7)
body.write_uint8(9)
@@ -94,15 +27,23 @@ def test_block_header_carries_version_and_size(self) -> None:
assert size == 2
def test_block_payload_follows_header(self) -> None:
- writer = BinaryWriter()
+ writer = FamiTrackerWriter()
with writer.block(Block("INFO", 1)) as body:
body.write_uint8(7)
body.write_uint8(9)
payload_offset = BLOCK_NAME_LENGTH + 8
assert writer.data[payload_offset:] == b"\x07\x09"
+ def test_a_nested_block_frames_inside_its_parent(self) -> None:
+ writer = FamiTrackerWriter()
+ with writer.block(Block("INFO", 1)) as body:
+ with body.block(Block("PARAMS", 2)) as inner:
+ inner.write_uint8(7)
+ size = struct.unpack_from(" None:
- writer = BinaryWriter()
+ writer = FamiTrackerWriter()
with pytest.raises(ValueError):
with writer.block(Block("N" * (BLOCK_NAME_LENGTH + 1), 1)):
pass
diff --git a/tests/unit/sampletones_core/formats/famitracker/test_builder.py b/tests/unit/sampletones_core/formats/famitracker/test_builder.py
index 0d04f2812..ee5cf4e86 100644
--- a/tests/unit/sampletones_core/formats/famitracker/test_builder.py
+++ b/tests/unit/sampletones_core/formats/famitracker/test_builder.py
@@ -1,7 +1,7 @@
import numpy as np
import pytest
-from sampletones_core.constants.enums import GeneratorName
+from sampletones_core.constants.enums import ChannelName
from sampletones_core.formats.famitracker.builder import (
build_instrument_table,
project_to_module,
@@ -23,13 +23,12 @@
NoteValue,
)
from sampletones_core.formats.famitracker.specification.sequences import (
- LOOP_FROM_START,
NO_LOOP_POINT,
SequenceKind,
)
from sampletones_core.instructions.implementation.pulse import PulseInstruction
-from sampletones_core.project.instruments.sample import Sample
from sampletones_core.project.project import Project
+from sampletones_core.project.voices.sample import Sample
from .conftest import RECONSTRUCTION_LENGTH, ProjectFixture, build_reconstruction
@@ -46,12 +45,12 @@ def test_one_instrument_per_generator_slice(self, project_fixture: ProjectFixtur
def test_slot_maps_sample_and_generator_to_index(self, project_fixture: ProjectFixture) -> None:
_, slots = build_instrument_table(project_fixture.project)
- assert slots[(project_fixture.lead.id, GeneratorName.PULSE1)].index == 0
- assert slots[(project_fixture.bell.id, GeneratorName.TRIANGLE)].index == 4
+ assert slots[(project_fixture.lead.id, ChannelName.PULSE1)].index == 0
+ assert slots[(project_fixture.bell.id, ChannelName.TRIANGLE)].index == 4
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 == LEAD_PITCH
+ assert slots[(project_fixture.lead.id, ChannelName.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.
@@ -64,8 +63,8 @@ def test_slot_keeps_its_pitch_after_an_arpeggio_edit(self, project_fixture: Proj
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,
+ project_fixture.lead.reconstruction.update_channel_data(
+ ChannelName.PULSE1,
arpeggiated,
np.ones(RECONSTRUCTION_LENGTH, dtype=np.float32),
LEAD_PITCH,
@@ -74,27 +73,25 @@ def test_slot_keeps_its_pitch_after_an_arpeggio_edit(self, project_fixture: Proj
instruments, slots = build_instrument_table(project_fixture.project)
- slot = slots[(project_fixture.lead.id, GeneratorName.PULSE1)]
+ slot = slots[(project_fixture.lead.id, ChannelName.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:
+ def test_a_recordings_sequences_state_no_loop_point(self, project_fixture: ProjectFixture) -> None:
+ """A recording is the fixed run of frames its conversion found, so nothing in it circles."""
instruments, slots = build_instrument_table(project_fixture.project)
- pad_index = slots[(project_fixture.pad.id, GeneratorName.PULSE1)].index
- pad = instruments[pad_index]
- assert pad.sequences[SequenceKind.VOLUME].loop_point == LOOP_FROM_START
+ indices = [slots[(voice.id, ChannelName.PULSE1)].index for voice in (project_fixture.lead, project_fixture.pad)]
- def test_non_looping_sample_leaves_loop_disabled(self, project_fixture: ProjectFixture) -> None:
- instruments, slots = build_instrument_table(project_fixture.project)
- lead_index = slots[(project_fixture.lead.id, GeneratorName.PULSE1)].index
- assert instruments[lead_index].sequences[SequenceKind.VOLUME].loop_point == NO_LOOP_POINT
+ points = [instruments[index].sequences[SequenceKind.VOLUME].loop_point for index in indices]
+
+ assert points == [NO_LOOP_POINT, NO_LOOP_POINT]
def test_exceeding_max_instruments_raises(self) -> None:
project = Project.create()
for number in range(MAX_INSTRUMENTS + 1):
instructions = [PulseInstruction(on=True, pitch=60, volume=15, duty_cycle=0)]
- reconstruction = build_reconstruction({GeneratorName.PULSE1: instructions})
- project.samples.append(Sample(name=f"sample-{number}", reconstruction=reconstruction))
+ reconstruction = build_reconstruction({ChannelName.PULSE1: instructions})
+ project.voices.append(Sample(name=f"sample-{number}", reconstruction=reconstruction))
with pytest.raises(ValueError):
build_instrument_table(project)
diff --git a/tests/unit/sampletones_core/formats/famitracker/test_footprint.py b/tests/unit/sampletones_core/formats/famitracker/test_footprint.py
index ca7a69fc0..1ee9d2ff0 100644
--- a/tests/unit/sampletones_core/formats/famitracker/test_footprint.py
+++ b/tests/unit/sampletones_core/formats/famitracker/test_footprint.py
@@ -4,8 +4,9 @@
import numpy as np
import pytest
-from sampletones_core.constants.enums import GeneratorName
+from sampletones_core.constants.enums import ChannelName
from sampletones_core.exporters.feature import Features
+from sampletones_core.features.envelope import Envelope
from sampletones_core.formats.famitracker.builder import build_instrument
from sampletones_core.formats.famitracker.footprint import (
InstrumentFootprint,
@@ -39,15 +40,21 @@ def build_features(
volume: Sequence[int],
arpeggio: Sequence[int],
duty_cycle: Optional[Sequence[int]],
+ *,
+ loop_point: Optional[int] = None,
) -> Features:
- """Builds the envelopes of one generator slice, leaving the pitch dimensions unused."""
+ """Builds the envelopes of one channel slice, leaving the pitch dimensions unused."""
+
+ def envelope(items: Sequence[int]) -> Envelope[int]:
+ return Envelope(items=tuple(items), loop_point=loop_point if items else None)
+
return Features(
initial_pitch=REFERENCE_PITCH,
- volume=np.array(volume, dtype=int),
- arpeggio=np.array(arpeggio, dtype=int),
+ volume=envelope(volume),
+ arpeggio=envelope(arpeggio),
pitch=None,
hi_pitch=None,
- duty_cycle=None if duty_cycle is None else np.array(duty_cycle, dtype=int),
+ duty_cycle=None if duty_cycle is None else envelope(duty_cycle),
)
@@ -55,37 +62,31 @@ class TestFeaturesFootprint(BaseTestSuite):
@dataclass(frozen=True, kw_only=True)
class TestCase(BaseRegularTestCase):
features: Features
- loop: bool
expected: InstrumentFootprint
test_cases = (
TestCase(
features=build_features([15, 12, 9, 0], [0, 2, 4], [1, 1, 2]),
- loop=False,
expected=InstrumentFootprint(instrument_bytes=9, sequence_bytes=22),
label="pulse_one_shot",
),
TestCase(
- features=build_features([15, 12, 9, 0], [0, 2, 4], [1, 1, 2]),
- loop=True,
- expected=InstrumentFootprint(instrument_bytes=9, sequence_bytes=21),
+ features=build_features([15, 12, 9, 0], [0, 2, 4], [1, 1, 2], loop_point=0),
+ expected=InstrumentFootprint(instrument_bytes=9, sequence_bytes=22),
label="pulse_loop",
),
TestCase(
features=build_features([15, 0], [0], [0]),
- loop=False,
expected=InstrumentFootprint(instrument_bytes=9, sequence_bytes=16),
label="dimensions_of_differing_lengths",
),
TestCase(
features=build_features([15, 12, 0], [0, 1], None),
- loop=False,
expected=InstrumentFootprint(instrument_bytes=7, sequence_bytes=13),
label="triangle",
),
TestCase(
features=build_features([], [], None),
- loop=False,
expected=InstrumentFootprint(instrument_bytes=3, sequence_bytes=0),
label="silent",
),
@@ -95,7 +96,6 @@ class TestCase(BaseRegularTestCase):
[0] * OVER_LONG_LENGTH,
None,
),
- loop=False,
expected=InstrumentFootprint(instrument_bytes=7, sequence_bytes=512),
label="capped_at_the_sequence_limit",
),
@@ -105,7 +105,6 @@ class TestCase(BaseRegularTestCase):
[0] * MAX_SEQUENCE_ITEMS,
[0] * MAX_SEQUENCE_ITEMS,
),
- loop=False,
expected=InstrumentFootprint(instrument_bytes=9, sequence_bytes=768),
label="largest_instrument_famitracker_holds",
),
@@ -116,17 +115,17 @@ def test_both_regions_are_measured_from_the_populated_sequences(
self,
test_case: TestCase,
) -> None:
- assert features_footprint(test_case.features, loop=test_case.loop) == test_case.expected
+ assert features_footprint(test_case.features) == test_case.expected
@pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label)
def test_the_built_instrument_measures_the_same(self, test_case: TestCase) -> None:
"""Both entry points measure one export, so a slice reads the same either way."""
- instrument = build_instrument(0, test_case.label, test_case.features, loop=test_case.loop)
+ instrument = build_instrument(0, test_case.label, test_case.features)
assert instrument_footprint(instrument) == test_case.expected
@pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label)
def test_the_total_sums_both_regions(self, test_case: TestCase) -> None:
- footprint = features_footprint(test_case.features, loop=test_case.loop)
+ footprint = features_footprint(test_case.features)
assert footprint.total_bytes == test_case.expected.instrument_bytes + test_case.expected.sequence_bytes
@@ -161,31 +160,23 @@ class TestReconstructionFootprints:
def test_one_entry_per_playing_channel(self) -> None:
"""The sample holds every channel; the two that play are the two an export writes."""
sample = dual_generator_sample("bell", pulse_pitch=72, triangle_pitch=36)
- footprints = reconstruction_footprints(sample.reconstruction, loop=sample.loop)
- assert set(footprints) == {GeneratorName.PULSE1, GeneratorName.TRIANGLE}
+ footprints = reconstruction_footprints(sample.reconstruction)
+ assert set(footprints) == {ChannelName.PULSE1, ChannelName.TRIANGLE}
def test_a_triangle_slice_carries_one_sequence_less_than_a_pulse_slice(self) -> None:
"""Triangle exports volume and arpeggio; pulse adds duty, hence one more pointer."""
sample = dual_generator_sample("bell", pulse_pitch=72, triangle_pitch=36)
- footprints = reconstruction_footprints(sample.reconstruction, loop=sample.loop)
- pulse = footprints[GeneratorName.PULSE1]
- triangle = footprints[GeneratorName.TRIANGLE]
+ footprints = reconstruction_footprints(sample.reconstruction)
+ pulse = footprints[ChannelName.PULSE1]
+ triangle = footprints[ChannelName.TRIANGLE]
assert pulse.instrument_bytes - triangle.instrument_bytes == SEQUENCE_POINTER_BYTES
- def test_each_channel_is_measured_under_the_given_loop_flag(self) -> None:
+ def test_each_channel_is_measured_as_its_own_envelopes_state_them(self) -> None:
sample = pulse_sample("lead", pitch=60)
features = sample.reconstruction.export()
- for loop in (False, True):
- assert reconstruction_footprints(sample.reconstruction, loop=loop) == {
- generator_name: features_footprint(feature, loop=loop)
- for generator_name, feature in features.items()
- if feature.has_frames
- }
-
- def test_looping_costs_the_shortest_dimensions_length(self) -> None:
- """A looping instrument shares the shortest dimension's length, so it stores fewer items."""
- sample = pulse_sample("lead", pitch=60)
- one_shot = total_footprint(reconstruction_footprints(sample.reconstruction, loop=False).values())
- looping = total_footprint(reconstruction_footprints(sample.reconstruction, loop=True).values())
- assert one_shot.instrument_bytes == looping.instrument_bytes
- assert looping.sequence_bytes < one_shot.sequence_bytes
+
+ assert reconstruction_footprints(sample.reconstruction) == {
+ channel_name: features_footprint(feature)
+ for channel_name, feature in features.items()
+ if feature.has_frames
+ }
diff --git a/tests/unit/sampletones_core/formats/famitracker/test_fti.py b/tests/unit/sampletones_core/formats/famitracker/test_fti.py
index 8503bd4bc..b916be82d 100644
--- a/tests/unit/sampletones_core/formats/famitracker/test_fti.py
+++ b/tests/unit/sampletones_core/formats/famitracker/test_fti.py
@@ -1,15 +1,47 @@
-import struct
-from dataclasses import dataclass
from pathlib import Path
-from typing import List, Optional, Tuple
+from typing import Final, Optional
import numpy as np
-
-from sampletones_core.formats.famitracker.instrument import write_fti
+import pytest
+
+from sampletones_core.constants.general import MAX_VOLUME
+from sampletones_core.exporters.feature import Features
+from sampletones_core.features.envelope import Envelope
+from sampletones_core.formats.binary import BinaryWriter
+from sampletones_core.formats.famitracker.instrument import (
+ fti_bytes_to_instrument,
+ instrument_to_fti_bytes,
+ read_fti,
+ 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.file import FTI_MAGIC, FTI_VERSION
+from sampletones_core.formats.famitracker.specification.instruments import (
+ EMPTY_DPCM_ASSIGNMENTS,
+ EMPTY_DPCM_SAMPLES,
+ INSTRUMENT_TYPE_2A03,
+ STANDALONE_INSTRUMENT_INDEX,
+)
+from sampletones_core.formats.famitracker.specification.sequences import (
+ DEFAULT_SEQUENCE_SETTING,
+ MAX_SEQUENCE_ITEMS,
+ NO_LOOP_POINT,
+ NO_RELEASE_POINT,
+ SEQUENCE_COUNT_2A03,
+ SEQUENCE_DISABLED,
+ SEQUENCE_ENABLED,
+ SequenceKind,
+)
+from sampletones_shared.exceptions import (
+ IncompatibleInstrumentVersionError,
+ InvalidInstrumentValuesError,
+ MalformedInstrumentError,
+ NotAnInstrumentFileError,
+ UnsupportedInstrumentTypeError,
+)
GOLDEN_INSTRUMENT_NAME = "Test Instrument"
GOLDEN_VOLUME = np.array([15, 12, 8, 0])
@@ -25,6 +57,37 @@
b"\x00\x00\x00\x00\x00\x00\x00\x00"
)
+SEQUENCE_COUNT_OFFSET = 26
+TYPE_OFFSET = 6
+SIGNATURE_LENGTH = 3
+
+
+def fti_stating_volume_items(count: int) -> bytes:
+ """A file whose volume sequence states ``count`` items, written field by field."""
+ writer = BinaryWriter()
+ writer.write_bytes(FTI_MAGIC)
+ writer.write_bytes(FTI_VERSION)
+ writer.write_uint8(INSTRUMENT_TYPE_2A03)
+ writer.write_counted_string("Long")
+ writer.write_int8(SEQUENCE_COUNT_2A03)
+ writer.write_int8(SEQUENCE_ENABLED)
+ writer.write_uint32(count)
+ writer.write_int32(NO_LOOP_POINT)
+ writer.write_int32(NO_RELEASE_POINT)
+ writer.write_uint32(DEFAULT_SEQUENCE_SETTING)
+ for _ in range(count):
+ writer.write_int8(MAX_VOLUME)
+
+ for _ in range(SEQUENCE_COUNT_2A03 - 1):
+ writer.write_int8(SEQUENCE_DISABLED)
+
+ writer.write_uint32(EMPTY_DPCM_ASSIGNMENTS)
+ writer.write_uint32(EMPTY_DPCM_SAMPLES)
+ return writer.data
+
+
+REFERENCE_PITCH: Final[int] = 60
+
def build_instrument(
name: str,
@@ -34,86 +97,32 @@ def build_instrument(
pitch: Optional[np.ndarray] = None,
hi_pitch: Optional[np.ndarray] = None,
duty_cycle: Optional[np.ndarray] = None,
- loop: bool = False,
+ loop_point: Optional[int] = None,
index: int = 0,
) -> Instrument2A03:
+ def envelope(items: Optional[np.ndarray]) -> Envelope[int]:
+ values = () if items is None else tuple(int(item) for item in items)
+ return Envelope[int](items=values, loop_point=loop_point if values else None)
+
sequences = features_to_instrument_sequences(
- volume=volume,
- arpeggio=arpeggio if arpeggio is not None else np.array([], dtype=int),
- pitch=pitch,
- hi_pitch=hi_pitch,
- duty_cycle=duty_cycle,
- loop=loop,
+ Features(
+ initial_pitch=REFERENCE_PITCH,
+ volume=envelope(volume),
+ arpeggio=envelope(arpeggio),
+ pitch=None if pitch is None else envelope(pitch),
+ hi_pitch=None if hi_pitch is None else envelope(hi_pitch),
+ duty_cycle=None if duty_cycle is None else envelope(duty_cycle),
+ )
)
return Instrument2A03(index=index, name=name, sequences=sequences)
-@dataclass
-class ParsedSequence:
- enabled: bool
- loop_point: int
- release_point: int
- setting: int
- items: List[int]
-
-
-@dataclass
-class ParsedFti:
- magic: bytes
- version: bytes
- instrument_type: int
- name: str
- sequences: List[ParsedSequence]
- dpcm_assignment_count: int
- dpcm_sample_count: int
-
-
-def _read(data: bytes, offset: int, fmt: str) -> Tuple[int, int]:
- size = struct.calcsize(fmt)
- (value,) = struct.unpack_from(fmt, data, offset)
- return value, offset + size
-
-
-def parse_fti(data: bytes) -> ParsedFti:
- offset = 0
- magic = data[offset : offset + 3]
- version = data[offset + 3 : offset + 6]
- offset += 6
-
- instrument_type, offset = _read(data, offset, " Instrument2A03:
+ return build_instrument(
+ GOLDEN_INSTRUMENT_NAME,
+ volume=GOLDEN_VOLUME,
+ arpeggio=GOLDEN_ARPEGGIO,
+ duty_cycle=GOLDEN_DUTY_CYCLE,
)
@@ -124,63 +133,112 @@ class TestWriteFtiGoldenBytes:
def test_output_matches_golden(self, tmp_path: Path) -> None:
path = tmp_path / "golden.fti"
- instrument = build_instrument(
- GOLDEN_INSTRUMENT_NAME,
- volume=GOLDEN_VOLUME,
- arpeggio=GOLDEN_ARPEGGIO,
- duty_cycle=GOLDEN_DUTY_CYCLE,
- )
- write_fti(path, instrument)
+ write_fti(path, golden_instrument())
assert path.read_bytes() == GOLDEN_FTI_BYTES
-class TestWriteFtiRoundTrip:
- def test_header_and_type(self, tmp_path: Path) -> None:
- path = tmp_path / "instrument.fti"
- write_fti(path, build_instrument("Lead", volume=np.array([15, 0])))
- parsed = parse_fti(path.read_bytes())
- assert parsed.magic == b"FTI"
- assert parsed.version == b"2.4"
- assert parsed.instrument_type == 1
+class TestReadGoldenBytes:
+ """The reader takes the pinned bytes back into the instrument that wrote them."""
- def test_name_round_trips(self, tmp_path: Path) -> None:
- path = tmp_path / "instrument.fti"
- write_fti(path, build_instrument("Bass Line", volume=np.array([15, 0])))
- parsed = parse_fti(path.read_bytes())
- assert parsed.name == "Bass Line"
+ def test_the_golden_bytes_read_back_as_the_instrument(self) -> None:
+ assert fti_bytes_to_instrument(GOLDEN_FTI_BYTES) == golden_instrument()
- def test_all_five_sequence_slots_present(self, tmp_path: Path) -> None:
- path = tmp_path / "instrument.fti"
- write_fti(path, build_instrument("Lead", volume=np.array([15, 0])))
- parsed = parse_fti(path.read_bytes())
- assert len(parsed.sequences) == 5
+ def test_the_name_comes_back(self) -> None:
+ assert fti_bytes_to_instrument(GOLDEN_FTI_BYTES).name == GOLDEN_INSTRUMENT_NAME
- def test_enabled_sequence_items_round_trip(self, tmp_path: Path) -> None:
- path = tmp_path / "instrument.fti"
+ def test_a_standalone_file_holds_the_first_slot(self) -> None:
+ assert fti_bytes_to_instrument(GOLDEN_FTI_BYTES).index == STANDALONE_INSTRUMENT_INDEX
+
+
+class TestFtiRoundTrip:
+ def test_the_bytes_come_back_the_same(self) -> None:
+ instrument = golden_instrument()
+ data = instrument_to_fti_bytes(instrument)
+ assert instrument_to_fti_bytes(fti_bytes_to_instrument(data)) == data
+
+ def test_the_name_round_trips(self) -> None:
+ instrument = build_instrument("Bass Line", volume=np.array([15, 0]))
+ assert fti_bytes_to_instrument(instrument_to_fti_bytes(instrument)).name == "Bass Line"
+
+ def test_all_five_sequence_slots_are_present(self) -> None:
+ instrument = fti_bytes_to_instrument(
+ instrument_to_fti_bytes(build_instrument("Lead", volume=np.array([15, 0])))
+ )
+ assert set(instrument.sequences) == set(SequenceKind)
+
+ def test_enabled_sequence_items_round_trip(self) -> None:
instrument = build_instrument("Lead", volume=np.array([15, 12, 8, 0]), arpeggio=np.array([0, 2, -3]))
- write_fti(path, instrument)
- parsed = parse_fti(path.read_bytes())
- assert parsed.sequences[0].enabled is True
- assert parsed.sequences[0].items == [15, 12, 8, 0]
- assert parsed.sequences[1].items == [0, 2, -3]
+ read = fti_bytes_to_instrument(instrument_to_fti_bytes(instrument))
+ assert read.sequences[SequenceKind.VOLUME].items == (15, 12, 8, 0)
+ assert read.sequences[SequenceKind.ARPEGGIO].items == (0, 2, -3)
- def test_missing_sequences_are_disabled(self, tmp_path: Path) -> None:
- path = tmp_path / "instrument.fti"
- write_fti(path, build_instrument("Lead", volume=np.array([15, 0])))
- parsed = parse_fti(path.read_bytes())
- assert parsed.sequences[2].enabled is False
- assert parsed.sequences[3].enabled is False
- assert parsed.sequences[4].enabled is False
+ def test_missing_sequences_come_back_disabled(self) -> None:
+ instrument = fti_bytes_to_instrument(
+ instrument_to_fti_bytes(build_instrument("Lead", volume=np.array([15, 0])))
+ )
+ assert not instrument.sequences[SequenceKind.PITCH].enabled
+ assert not instrument.sequences[SequenceKind.HI_PITCH].enabled
+ assert not instrument.sequences[SequenceKind.DUTY].enabled
- def test_loop_flag_sets_loop_point(self, tmp_path: Path) -> None:
- path = tmp_path / "instrument.fti"
- write_fti(path, build_instrument("Pad", volume=np.array([15, 10, 5]), loop=True))
- parsed = parse_fti(path.read_bytes())
- assert parsed.sequences[0].loop_point == 0
+ def test_the_loop_point_round_trips(self) -> None:
+ instrument = build_instrument("Pad", volume=np.array([15, 10, 5]), loop_point=0)
+ read = fti_bytes_to_instrument(instrument_to_fti_bytes(instrument))
+ assert read.sequences[SequenceKind.VOLUME].loop_point == 0
- def test_dpcm_section_is_empty(self, tmp_path: Path) -> None:
+ def test_a_written_file_reads_back(self, tmp_path: Path) -> None:
path = tmp_path / "instrument.fti"
- write_fti(path, build_instrument("Lead", volume=np.array([15, 0])))
- parsed = parse_fti(path.read_bytes())
- assert parsed.dpcm_assignment_count == 0
- assert parsed.dpcm_sample_count == 0
+ instrument = golden_instrument()
+ write_fti(path, instrument)
+ assert read_fti(path) == instrument
+
+ def test_a_missing_file_raises(self, tmp_path: Path) -> None:
+ with pytest.raises(FileNotFoundError):
+ read_fti(tmp_path / "absent.fti")
+
+
+class TestReadFtiRefusals:
+ def test_other_data_is_not_an_instrument_file(self) -> None:
+ with pytest.raises(NotAnInstrumentFileError):
+ fti_bytes_to_instrument(b"XXX" + GOLDEN_FTI_BYTES[SIGNATURE_LENGTH:])
+
+ def test_another_layout_version_is_refused(self) -> None:
+ with pytest.raises(IncompatibleInstrumentVersionError):
+ fti_bytes_to_instrument(b"FTI9.9" + GOLDEN_FTI_BYTES[TYPE_OFFSET:])
+
+ def test_the_refused_version_is_named(self) -> None:
+ with pytest.raises(IncompatibleInstrumentVersionError) as raised:
+ fti_bytes_to_instrument(b"FTI9.9" + GOLDEN_FTI_BYTES[TYPE_OFFSET:])
+
+ assert raised.value.actual_version == "9.9"
+ assert raised.value.expected_version == "2.4"
+
+ def test_another_chip_is_refused(self) -> None:
+ data = GOLDEN_FTI_BYTES[:TYPE_OFFSET] + b"\x05" + GOLDEN_FTI_BYTES[TYPE_OFFSET + 1 :]
+ with pytest.raises(UnsupportedInstrumentTypeError):
+ fti_bytes_to_instrument(data)
+
+ def test_another_sequence_count_is_refused(self) -> None:
+ data = GOLDEN_FTI_BYTES[:SEQUENCE_COUNT_OFFSET] + b"\x03" + GOLDEN_FTI_BYTES[SEQUENCE_COUNT_OFFSET + 1 :]
+ with pytest.raises(MalformedInstrumentError):
+ fti_bytes_to_instrument(data)
+
+ def test_a_file_cut_short_is_refused(self) -> None:
+ with pytest.raises(MalformedInstrumentError):
+ fti_bytes_to_instrument(GOLDEN_FTI_BYTES[:20])
+
+ def test_empty_data_is_refused(self) -> None:
+ with pytest.raises(MalformedInstrumentError):
+ fti_bytes_to_instrument(b"")
+
+ def test_a_sequence_longer_than_one_holds_is_refused(self) -> None:
+ with pytest.raises(InvalidInstrumentValuesError):
+ fti_bytes_to_instrument(fti_stating_volume_items(MAX_SEQUENCE_ITEMS + 1))
+
+ def test_a_sequence_of_the_length_one_holds_is_read(self) -> None:
+ instrument = fti_bytes_to_instrument(fti_stating_volume_items(MAX_SEQUENCE_ITEMS))
+ assert len(instrument.sequences[SequenceKind.VOLUME].items) == MAX_SEQUENCE_ITEMS
+
+ def test_a_name_that_is_not_text_is_refused(self) -> None:
+ data = GOLDEN_FTI_BYTES[:11] + b"\xff" * 15 + GOLDEN_FTI_BYTES[SEQUENCE_COUNT_OFFSET:]
+ with pytest.raises(MalformedInstrumentError):
+ fti_bytes_to_instrument(data)
diff --git a/tests/unit/sampletones_core/formats/famitracker/test_ftm.py b/tests/unit/sampletones_core/formats/famitracker/test_ftm.py
index 815bd7509..4192d21ce 100644
--- a/tests/unit/sampletones_core/formats/famitracker/test_ftm.py
+++ b/tests/unit/sampletones_core/formats/famitracker/test_ftm.py
@@ -30,7 +30,7 @@
EMPTY_VOLUME,
NoteValue,
)
-from sampletones_core.formats.famitracker.specification.sequences import SequenceKind
+from sampletones_core.formats.famitracker.specification.sequences import NO_LOOP_POINT, SequenceKind
from tests.suite.famitracker import ParsedModule, ParsedSequence, parse_ftm
from .conftest import ProjectFixture
@@ -136,11 +136,18 @@ def test_volume_reference_resolves_to_populated_sequence(self, project_fixture:
class TestSequencesBlock:
- def test_looping_instrument_sequence_loops_from_start(self, project_fixture: ProjectFixture) -> None:
+ def test_a_recordings_sequence_is_pooled_with_its_items_and_no_point(
+ self,
+ project_fixture: ProjectFixture,
+ ) -> None:
parsed = _parsed(project_fixture)
pad = next(instrument for instrument in parsed.instruments if instrument.name.startswith("pad"))
_, index = pad.sequence_refs[int(SequenceKind.VOLUME)]
- assert _pooled(parsed, SequenceKind.VOLUME, index).loop_point == 0
+
+ sequence = _pooled(parsed, SequenceKind.VOLUME, index)
+
+ assert sequence.items
+ assert sequence.loop_point == NO_LOOP_POINT
class TestFramesBlock:
diff --git a/tests/unit/sampletones_core/formats/famitracker/test_instrument_module.py b/tests/unit/sampletones_core/formats/famitracker/test_instrument_module.py
new file mode 100644
index 000000000..19bf96416
--- /dev/null
+++ b/tests/unit/sampletones_core/formats/famitracker/test_instrument_module.py
@@ -0,0 +1,137 @@
+from typing import Final, List, Tuple
+
+import pytest
+
+from sampletones_core.constants.enums import ChannelName
+from sampletones_core.features.envelope import Envelope
+from sampletones_core.formats.famitracker.builder import build_instrument_table, project_to_module
+from sampletones_core.formats.famitracker.model.pattern import PatternData
+from sampletones_core.formats.famitracker.notes import period_to_note_cell, pitch_to_note_cell
+from sampletones_core.formats.famitracker.specification.channels import CHANNEL_TO_ID
+from sampletones_core.formats.famitracker.specification.sequences import (
+ LOOP_FROM_START,
+ NO_LOOP_POINT,
+ SequenceKind,
+)
+from sampletones_core.project.patterns.row import Row
+from sampletones_core.project.project import Project
+from sampletones_core.project.voices.envelopes import InstrumentEnvelopes
+from sampletones_core.project.voices.instrument import Instrument
+from sampletones_core.project.voices.note_on import NoteOn
+
+ROWS_PER_PATTERN: Final[int] = 4
+VOLUME: Final[Tuple[int, ...]] = (15, 12, 9)
+ARPEGGIO: Final[Tuple[int, ...]] = (0, 4, 7)
+DUTY_CYCLE: Final[Tuple[int, ...]] = (2,)
+TAIL_LOOP_POINT: Final[int] = 1
+TRANSPOSE: Final[int] = 5
+
+
+def _instrument(loop_point: int | None = None) -> Instrument:
+ return Instrument(
+ name="Lead",
+ envelopes=InstrumentEnvelopes(
+ volume=Envelope(items=VOLUME, loop_point=loop_point),
+ arpeggio=Envelope(items=ARPEGGIO, loop_point=loop_point),
+ duty_cycle=Envelope(items=DUTY_CYCLE, loop_point=None if loop_point is None else 0),
+ ),
+ )
+
+
+def _project(instrument: Instrument, *channels: ChannelName, transpose: int = 0) -> Project:
+ project = Project.create(title="Demo", rows_per_pattern=ROWS_PER_PATTERN)
+ project.voices.append(instrument)
+ for channel in channels:
+ pattern = project.song[channel].ensure_pattern(0, ROWS_PER_PATTERN)
+ pattern.rows[0] = Row(command=NoteOn(voice_id=instrument.id), transpose=transpose)
+ project.song.set_order_entry(0, channel, 0)
+
+ return project
+
+
+def _rows(patterns: List[PatternData], channel: ChannelName) -> List[object]:
+ return [row for pattern in patterns if pattern.channel == CHANNEL_TO_ID[channel] for row in pattern.rows]
+
+
+class TestAnInstrumentReachesTheModule:
+ def test_an_instrument_used_on_several_channels_is_written_once(self) -> None:
+ instrument = _instrument()
+ project = _project(instrument, ChannelName.PULSE1, ChannelName.PULSE2, ChannelName.NOISE)
+
+ instruments, slots = build_instrument_table(project)
+
+ assert len(instruments) == 1
+ assert instruments[0].name == "Lead"
+ assert {slots[(instrument.id, channel)].index for channel in ChannelName.items()} == {0}
+
+ def test_the_table_entry_carries_every_dimension_the_instrument_writes(self) -> None:
+ instruments, _ = build_instrument_table(_project(_instrument(), ChannelName.PULSE1))
+
+ sequences = instruments[0].sequences
+ assert sequences[SequenceKind.VOLUME].items == VOLUME
+ assert sequences[SequenceKind.ARPEGGIO].items == ARPEGGIO
+ assert sequences[SequenceKind.DUTY].items == DUTY_CYCLE
+
+ def test_a_one_shot_leaves_every_loop_point_unset(self) -> None:
+ instruments, _ = build_instrument_table(_project(_instrument(), ChannelName.PULSE1))
+
+ assert all(sequence.loop_point == NO_LOOP_POINT for sequence in instruments[0].sequences.values())
+
+ def test_each_populated_sequence_carries_the_point_its_dimension_states(self) -> None:
+ instruments, _ = build_instrument_table(_project(_instrument(TAIL_LOOP_POINT), ChannelName.PULSE1))
+
+ sequences = instruments[0].sequences
+
+ assert sequences[SequenceKind.VOLUME].loop_point == TAIL_LOOP_POINT
+ assert sequences[SequenceKind.ARPEGGIO].loop_point == TAIL_LOOP_POINT
+ assert sequences[SequenceKind.DUTY].loop_point == LOOP_FROM_START
+
+ def test_a_shorter_dimension_stands_at_its_own_length(self) -> None:
+ """A tracker advances each sequence on its own counter, so each keeps the length it holds."""
+ instrument = Instrument(
+ name="Lead",
+ envelopes=InstrumentEnvelopes(
+ volume=Envelope(items=VOLUME, loop_point=TAIL_LOOP_POINT),
+ duty_cycle=Envelope(items=DUTY_CYCLE),
+ ),
+ )
+ instruments, _ = build_instrument_table(_project(instrument, ChannelName.PULSE1))
+
+ duty = instruments[0].sequences[SequenceKind.DUTY]
+ assert duty.items == DUTY_CYCLE
+ assert duty.loop_point == NO_LOOP_POINT
+
+ def test_a_looping_instrument_still_repeats_from_the_start(self) -> None:
+ instruments, _ = build_instrument_table(_project(_instrument(LOOP_FROM_START), ChannelName.PULSE1))
+
+ populated = [sequence for sequence in instruments[0].sequences.values() if sequence.items]
+ assert all(sequence.loop_point == LOOP_FROM_START for sequence in populated)
+
+
+class TestTheRowsNameTheInstrumentsRoot:
+ @pytest.mark.parametrize(
+ "channel",
+ [ChannelName.PULSE1, ChannelName.PULSE2, ChannelName.TRIANGLE],
+ )
+ def test_a_tonal_row_states_the_root_moved_by_its_transpose(self, channel: ChannelName) -> None:
+ instrument = _instrument()
+ module = project_to_module(_project(instrument, channel, transpose=TRANSPOSE))
+
+ cell = pitch_to_note_cell(instrument.initial_pitch + TRANSPOSE)
+ row = _rows(list(module.track.patterns), channel)[0]
+ assert (row.note, row.octave) == (cell.note, cell.octave)
+
+ def test_a_noise_row_states_the_period_root_moved_by_its_transpose(self) -> None:
+ instrument = _instrument()
+ module = project_to_module(_project(instrument, ChannelName.NOISE, transpose=TRANSPOSE))
+
+ cell = period_to_note_cell(instrument.initial_period + TRANSPOSE)
+ row = _rows(list(module.track.patterns), ChannelName.NOISE)[0]
+ assert (row.note, row.octave) == (cell.note, cell.octave)
+
+ def test_every_channel_names_the_one_instrument(self) -> None:
+ instrument = _instrument()
+ module = project_to_module(_project(instrument, *ChannelName.items()))
+
+ for channel in ChannelName.items():
+ assert _rows(list(module.track.patterns), channel)[0].instrument == 0
diff --git a/tests/unit/sampletones_core/formats/famitracker/test_voice.py b/tests/unit/sampletones_core/formats/famitracker/test_voice.py
new file mode 100644
index 000000000..c3c2d2383
--- /dev/null
+++ b/tests/unit/sampletones_core/formats/famitracker/test_voice.py
@@ -0,0 +1,255 @@
+from typing import Dict, Mapping, Tuple
+
+import pytest
+
+from sampletones_core.features import RESTING_REFERENCE_PERIOD, RESTING_REFERENCE_PITCH
+from sampletones_core.features.envelope import Envelope
+from sampletones_core.formats.famitracker.builder import build_instrument
+from sampletones_core.formats.famitracker.instrument import (
+ fti_bytes_to_instrument,
+ instrument_to_fti_bytes,
+)
+from sampletones_core.formats.famitracker.model.instrument import Instrument2A03
+from sampletones_core.formats.famitracker.model.sequence import InstrumentSequence
+from sampletones_core.formats.famitracker.specification.instruments import (
+ STANDALONE_INSTRUMENT_INDEX,
+)
+from sampletones_core.formats.famitracker.specification.sequences import SequenceKind
+from sampletones_core.formats.famitracker.voice import (
+ ImportedVoice,
+ InstrumentOmission,
+ instrument_to_voice,
+)
+from sampletones_core.project.voices.envelopes import InstrumentEnvelopes
+from sampletones_core.project.voices.instrument import Instrument
+from sampletones_shared.exceptions import InvalidInstrumentValuesError
+
+ARPEGGIO_SCHEME_SETTING = 2
+
+
+def written(
+ kind: SequenceKind,
+ items: Tuple[int, ...],
+ **fields: int,
+) -> InstrumentSequence:
+ return InstrumentSequence(kind=kind, items=items, **fields)
+
+
+def tracker_instrument(
+ *sequences: InstrumentSequence,
+ name: str = "Lead",
+) -> Instrument2A03:
+ written_sequences: Dict[SequenceKind, InstrumentSequence] = {
+ kind: InstrumentSequence(kind=kind) for kind in SequenceKind
+ }
+ for sequence in sequences:
+ written_sequences[sequence.kind] = sequence
+
+ return Instrument2A03(
+ index=STANDALONE_INSTRUMENT_INDEX,
+ name=name,
+ sequences=written_sequences,
+ )
+
+
+def imported(*sequences: InstrumentSequence) -> ImportedVoice:
+ return instrument_to_voice(tracker_instrument(*sequences))
+
+
+class TestTheEnvelopesAVoiceTakes:
+ def test_the_three_dimensions_come_across(self) -> None:
+ voice = imported(
+ written(SequenceKind.VOLUME, (15, 8, 0)),
+ written(SequenceKind.ARPEGGIO, (0, 3, 7)),
+ written(SequenceKind.DUTY, (0, 1, 2)),
+ ).voice
+
+ assert voice.envelopes == InstrumentEnvelopes(
+ volume=Envelope(items=(15, 8, 0)), arpeggio=Envelope(items=(0, 3, 7)), duty_cycle=Envelope(items=(0, 1, 2))
+ )
+
+ def test_a_dimension_the_instrument_leaves_out_stays_empty(self) -> None:
+ voice = imported(written(SequenceKind.VOLUME, (15, 0))).voice
+ assert voice.envelopes == InstrumentEnvelopes(volume=Envelope(items=(15, 0)))
+
+ def test_the_name_comes_across(self) -> None:
+ assert imported(written(SequenceKind.VOLUME, (15,))).voice.name == "Lead"
+
+ def test_the_voice_rests_on_the_roots_a_voice_added_by_hand_does(self) -> None:
+ voice = imported(written(SequenceKind.VOLUME, (15,))).voice
+ assert voice.initial_pitch == RESTING_REFERENCE_PITCH
+ assert voice.initial_period == RESTING_REFERENCE_PERIOD
+
+ def test_an_item_outside_the_range_a_dimension_holds_is_refused(self) -> None:
+ with pytest.raises(InvalidInstrumentValuesError):
+ imported(written(SequenceKind.VOLUME, (99,)))
+
+
+class TestTheLoopPointsAVoiceTakes:
+ """A tracker states a point per sequence, and each dimension of the voice takes its own."""
+
+ def test_each_sequence_brings_its_own_point(self) -> None:
+ envelopes = imported(
+ written(SequenceKind.VOLUME, (15, 8), loop_point=1),
+ written(SequenceKind.ARPEGGIO, (0, 3), loop_point=0),
+ ).voice.envelopes
+
+ assert envelopes.volume.loop_point == 1
+ assert envelopes.arpeggio.loop_point == 0
+
+ def test_a_sequence_stating_no_loop_holds_its_last_item(self) -> None:
+ envelopes = imported(written(SequenceKind.VOLUME, (15, 0))).voice.envelopes
+ assert envelopes.volume.loop_point is None
+
+ def test_an_instrument_with_nothing_written_states_no_point(self) -> None:
+ assert all(envelope.loop_point is None for envelope in imported().voice.envelopes.envelope_map.values())
+
+ def test_a_loop_point_before_the_first_item_holds_the_last_item(self) -> None:
+ envelopes = imported(written(SequenceKind.VOLUME, (15, 0), loop_point=-4)).voice.envelopes
+ assert envelopes.volume.loop_point is None
+
+ def test_a_loop_point_past_the_items_holds_the_last_item(self) -> None:
+ envelopes = imported(written(SequenceKind.VOLUME, (15, 0), loop_point=9)).voice.envelopes
+ assert envelopes.volume.loop_point is None
+
+
+class TestWhatTheInstrumentStatesPastTheVoice:
+ @staticmethod
+ def omissions(*sequences: InstrumentSequence) -> Mapping[InstrumentOmission, bool]:
+ reported = imported(*sequences).omissions
+ return {omission: omission in reported for omission in InstrumentOmission}
+
+ def test_a_plain_instrument_leaves_nothing_behind(self) -> None:
+ assert imported(written(SequenceKind.VOLUME, (15, 8, 0))).omissions == ()
+
+ def test_a_bend_the_tracker_accumulates_is_reported(self) -> None:
+ """The tracker pins the period only while an arpeggio runs, and this file writes none."""
+ assert self.omissions(written(SequenceKind.PITCH, (1, -1)))[InstrumentOmission.CUMULATIVE_BEND]
+
+ def test_a_hi_pitch_bend_the_tracker_accumulates_is_reported(self) -> None:
+ assert self.omissions(written(SequenceKind.HI_PITCH, (1,)))[InstrumentOmission.CUMULATIVE_BEND]
+
+ def test_a_bend_an_arpeggio_covers_is_carried_rather_than_reported(self) -> None:
+ """An arpeggio reloading the note every tick is what makes each bend item an offset."""
+ assert not self.omissions(
+ written(SequenceKind.ARPEGGIO, (0, 0)),
+ written(SequenceKind.PITCH, (1, -1)),
+ )[InstrumentOmission.CUMULATIVE_BEND]
+
+ def test_a_bend_beside_a_looping_arpeggio_is_carried(self) -> None:
+ assert not self.omissions(
+ written(SequenceKind.ARPEGGIO, (0,), loop_point=0),
+ written(SequenceKind.PITCH, (1, -1, 2, 3)),
+ )[InstrumentOmission.CUMULATIVE_BEND]
+
+ def test_a_bend_outrunning_its_arpeggio_is_reported(self) -> None:
+ assert self.omissions(
+ written(SequenceKind.ARPEGGIO, (0, 0)),
+ written(SequenceKind.PITCH, (1, -1, 2)),
+ )[InstrumentOmission.CUMULATIVE_BEND]
+
+ def test_the_bend_a_file_states_reaches_the_voice(self) -> None:
+ envelopes = imported(
+ written(SequenceKind.PITCH, (1, -1)),
+ written(SequenceKind.HI_PITCH, (2,)),
+ ).voice.envelopes
+
+ assert envelopes.pitch.items == (1, -1)
+ assert envelopes.hi_pitch.items == (2,)
+
+ def test_a_release_point_is_reported(self) -> None:
+ volume = written(SequenceKind.VOLUME, (15, 8), release_point=1)
+ assert self.omissions(volume)[InstrumentOmission.RELEASE_POINT]
+
+ def test_an_arpeggio_mode_other_than_absolute_is_reported(self) -> None:
+ arpeggio = written(SequenceKind.ARPEGGIO, (0, 3), setting=ARPEGGIO_SCHEME_SETTING)
+ assert self.omissions(arpeggio)[InstrumentOmission.ARPEGGIO_MODE]
+
+ def test_the_absolute_arpeggio_a_voice_reads_is_no_omission(self) -> None:
+ arpeggio = written(SequenceKind.ARPEGGIO, (0, 3))
+ assert not self.omissions(arpeggio)[InstrumentOmission.ARPEGGIO_MODE]
+
+ def test_a_point_per_sequence_is_carried_rather_than_reported(self) -> None:
+ """Each dimension holds a point of its own, so a file stating several leaves nothing behind."""
+ assert (
+ imported(
+ written(SequenceKind.VOLUME, (15, 8), loop_point=1),
+ written(SequenceKind.ARPEGGIO, (0, 3), loop_point=0),
+ ).omissions
+ == ()
+ )
+
+ def test_every_dimension_past_the_voice_is_named_at_once(self) -> None:
+ reported = imported(
+ written(SequenceKind.VOLUME, (15, 8), loop_point=1),
+ written(SequenceKind.ARPEGGIO, (0,), setting=ARPEGGIO_SCHEME_SETTING),
+ written(SequenceKind.PITCH, (1, -1), release_point=1),
+ ).omissions
+
+ assert set(reported) == set(InstrumentOmission)
+
+
+class TestAVoiceThroughAFileAndBack:
+ """A voice written here reaches a ``.fti`` and comes back holding what it held."""
+
+ @staticmethod
+ def round_trip(voice: Instrument) -> ImportedVoice:
+ tracker = build_instrument(
+ STANDALONE_INSTRUMENT_INDEX,
+ voice.name,
+ voice.instrument_features(),
+ )
+ return instrument_to_voice(fti_bytes_to_instrument(instrument_to_fti_bytes(tracker)))
+
+ def test_the_envelopes_come_back(self) -> None:
+ voice = Instrument(
+ name="Pad",
+ envelopes=InstrumentEnvelopes(
+ volume=Envelope(items=(15, 10, 5), loop_point=1),
+ arpeggio=Envelope(items=(0, 3, 7)),
+ duty_cycle=Envelope(items=(0, 1, 2)),
+ ),
+ )
+ assert self.round_trip(voice).voice.envelopes == voice.envelopes
+
+ def test_each_dimension_keeps_its_own_loop_point(self) -> None:
+ """A tracker advances every sequence on a counter of its own, and a file states each one."""
+ voice = Instrument(
+ name="Pad",
+ envelopes=InstrumentEnvelopes(
+ volume=Envelope(items=(15, 10, 5), loop_point=2),
+ arpeggio=Envelope(items=(0, 3, 7, 12), loop_point=0),
+ duty_cycle=Envelope(items=(0, 1)),
+ ),
+ )
+
+ envelopes = self.round_trip(voice).voice.envelopes
+
+ assert envelopes.volume.loop_point == 2
+ assert envelopes.arpeggio.loop_point == 0
+ assert envelopes.duty_cycle.loop_point is None
+
+ def test_the_name_comes_back(self) -> None:
+ voice = Instrument(name="Bass Line", envelopes=InstrumentEnvelopes(volume=Envelope(items=(15, 0))))
+ assert self.round_trip(voice).voice.name == "Bass Line"
+
+ def test_a_voice_of_its_own_making_leaves_nothing_behind(self) -> None:
+ voice = Instrument(
+ name="Pad",
+ envelopes=InstrumentEnvelopes(
+ volume=Envelope(items=(15, 10, 5), loop_point=1),
+ arpeggio=Envelope(items=(0, 3, 7)),
+ ),
+ )
+ assert self.round_trip(voice).omissions == ()
+
+ def test_a_shorter_dimension_comes_back_at_its_own_length(self) -> None:
+ """Every sequence stands at the length it was written, which is what a tracker reads."""
+ voice = Instrument(
+ name="Pad",
+ envelopes=InstrumentEnvelopes(
+ volume=Envelope(items=(15, 10, 5), loop_point=0),
+ duty_cycle=Envelope(items=(2,)),
+ ),
+ )
+ assert self.round_trip(voice).voice.envelopes.duty_cycle.items == (2,)
diff --git a/tests/unit/sampletones_core/formats/test_binary.py b/tests/unit/sampletones_core/formats/test_binary.py
new file mode 100644
index 000000000..a25b8f266
--- /dev/null
+++ b/tests/unit/sampletones_core/formats/test_binary.py
@@ -0,0 +1,214 @@
+import struct
+from dataclasses import dataclass
+from typing import Tuple
+
+import pytest
+
+from sampletones_core.formats.binary import BinaryReader, BinaryWriter
+from sampletones_shared.exceptions import TruncatedDataError
+from tests.suite.base import BaseTestSuite
+from tests.suite.case import BaseAutolabelTestCase
+
+
+class TestIntegerPrimitives(BaseTestSuite):
+ """Each named write packs the width and signedness its name states."""
+
+ @dataclass(frozen=True, kw_only=True)
+ class TestCase(BaseAutolabelTestCase):
+ expected: bytes
+ method: str
+ value: int
+
+ @property
+ def label(self) -> str:
+ return f"{self.method}-{self.value}"
+
+ test_cases: Tuple[TestCase, ...] = (
+ TestCase(method="write_uint8", value=0, expected=b"\x00"),
+ TestCase(method="write_uint8", value=255, expected=b"\xff"),
+ TestCase(method="write_int8", value=-128, expected=b"\x80"),
+ TestCase(method="write_int8", value=127, expected=b"\x7f"),
+ TestCase(method="write_uint16", value=0, expected=b"\x00\x00"),
+ TestCase(method="write_uint16", value=0x0440, expected=b"\x40\x04"),
+ TestCase(method="write_uint16", value=65535, expected=b"\xff\xff"),
+ TestCase(method="write_uint32", value=0x0440, expected=b"\x40\x04\x00\x00"),
+ TestCase(method="write_uint32", value=4294967295, expected=b"\xff\xff\xff\xff"),
+ TestCase(method="write_int32", value=-1, expected=b"\xff\xff\xff\xff"),
+ TestCase(method="write_int32", value=2147483647, expected=b"\xff\xff\xff\x7f"),
+ )
+
+ @staticmethod
+ def write(test_case: TestCase) -> BinaryWriter:
+ writer = BinaryWriter()
+ {
+ "write_uint8": writer.write_uint8,
+ "write_int8": writer.write_int8,
+ "write_uint16": writer.write_uint16,
+ "write_uint32": writer.write_uint32,
+ "write_int32": writer.write_int32,
+ }[test_case.method](test_case.value)
+ return writer
+
+ @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label)
+ def test_the_bytes_are_little_endian(self, test_case: TestCase) -> None:
+ assert self.write(test_case).data == test_case.expected
+
+ @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label)
+ def test_the_field_takes_the_width_its_name_states(self, test_case: TestCase) -> None:
+ assert len(self.write(test_case)) == len(test_case.expected)
+
+ def test_a_value_above_the_field_raises(self) -> None:
+ writer = BinaryWriter()
+ with pytest.raises(struct.error):
+ writer.write_uint16(65536)
+
+ def test_the_length_tracks_every_write(self) -> None:
+ writer = BinaryWriter()
+ writer.write_uint8(1)
+ writer.write_uint16(2)
+ writer.write_uint32(3)
+ assert len(writer) == 7
+
+
+class TestStringPrimitives:
+ def test_fixed_string_pads_with_nul(self) -> None:
+ writer = BinaryWriter()
+ writer.write_fixed_string("abc", 8)
+ assert writer.data == b"abc\x00\x00\x00\x00\x00"
+ assert len(writer) == 8
+
+ def test_fixed_string_truncates_to_length(self) -> None:
+ writer = BinaryWriter()
+ writer.write_fixed_string("abcdefgh", 4)
+ assert writer.data == b"abcd"
+
+ def test_counted_string_prefixes_length(self) -> None:
+ writer = BinaryWriter()
+ writer.write_counted_string("hi")
+ length = struct.unpack_from(" None:
+ writer = BinaryWriter()
+ writer.write_terminated_string("note")
+ assert writer.data == b"note\x00"
+
+
+class TestReadIntegerPrimitives(BaseTestSuite):
+ """Each named read takes the width and signedness its name states, back off the writer."""
+
+ @dataclass(frozen=True, kw_only=True)
+ class TestCase(BaseAutolabelTestCase):
+ expected: int
+ method: str
+
+ @property
+ def label(self) -> str:
+ return f"{self.method}-{self.expected}"
+
+ test_cases: Tuple[TestCase, ...] = (
+ TestCase(method="uint8", expected=0),
+ TestCase(method="uint8", expected=255),
+ TestCase(method="int8", expected=-128),
+ TestCase(method="int8", expected=127),
+ TestCase(method="uint32", expected=0x0440),
+ TestCase(method="uint32", expected=4294967295),
+ TestCase(method="int32", expected=-1),
+ TestCase(method="int32", expected=2147483647),
+ )
+
+ @staticmethod
+ def written(test_case: TestCase) -> bytes:
+ writer = BinaryWriter()
+ {
+ "uint8": writer.write_uint8,
+ "int8": writer.write_int8,
+ "uint32": writer.write_uint32,
+ "int32": writer.write_int32,
+ }[test_case.method](test_case.expected)
+ return writer.data
+
+ @staticmethod
+ def read(reader: BinaryReader, method: str) -> int:
+ return {
+ "uint8": reader.read_uint8,
+ "int8": reader.read_int8,
+ "uint32": reader.read_uint32,
+ "int32": reader.read_int32,
+ }[method]()
+
+ @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label)
+ def test_the_value_comes_back_off_the_bytes(self, test_case: TestCase) -> None:
+ reader = BinaryReader(self.written(test_case))
+ assert self.read(reader, test_case.method) == test_case.expected
+
+ @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label)
+ def test_the_field_leaves_the_reader_at_the_end(self, test_case: TestCase) -> None:
+ reader = BinaryReader(self.written(test_case))
+ self.read(reader, test_case.method)
+ assert reader.remaining == 0
+
+
+class TestReadingAdvances:
+ def test_each_read_takes_the_next_field(self) -> None:
+ writer = BinaryWriter()
+ writer.write_uint8(1)
+ writer.write_uint32(2)
+ writer.write_int32(-3)
+
+ reader = BinaryReader(writer.data)
+ assert (reader.read_uint8(), reader.read_uint32(), reader.read_int32()) == (1, 2, -3)
+
+ def test_the_remainder_counts_down(self) -> None:
+ reader = BinaryReader(b"\x01\x02\x03\x04")
+ reader.read_uint8()
+ assert reader.remaining == 3
+
+ def test_read_bytes_takes_the_count_asked_for(self) -> None:
+ reader = BinaryReader(b"abcdef")
+ assert reader.read_bytes(3) == b"abc"
+ assert reader.read_bytes(3) == b"def"
+
+ def test_counted_string_round_trips(self) -> None:
+ writer = BinaryWriter()
+ writer.write_counted_string("Bass Line")
+ assert BinaryReader(writer.data).read_counted_string() == "Bass Line"
+
+ def test_counted_string_leaves_what_follows_it(self) -> None:
+ writer = BinaryWriter()
+ writer.write_counted_string("hi")
+ writer.write_uint8(7)
+
+ reader = BinaryReader(writer.data)
+ reader.read_counted_string()
+ assert reader.read_uint8() == 7
+
+
+class TestReadingPastTheEnd:
+ def test_a_field_wider_than_the_remainder_raises(self) -> None:
+ reader = BinaryReader(b"\x01\x02")
+ with pytest.raises(TruncatedDataError):
+ reader.read_uint32()
+
+ def test_more_bytes_than_held_raises(self) -> None:
+ reader = BinaryReader(b"abc")
+ with pytest.raises(TruncatedDataError):
+ reader.read_bytes(4)
+
+ def test_an_empty_buffer_raises_on_the_first_read(self) -> None:
+ with pytest.raises(TruncatedDataError):
+ BinaryReader(b"").read_uint8()
+
+ def test_a_counted_string_longer_than_the_remainder_raises(self) -> None:
+ writer = BinaryWriter()
+ writer.write_uint32(10)
+ writer.write_bytes(b"hi")
+ with pytest.raises(TruncatedDataError):
+ BinaryReader(writer.data).read_counted_string()
+
+ def test_the_message_names_the_width_and_the_offset(self) -> None:
+ reader = BinaryReader(b"\x01\x02")
+ reader.read_uint8()
+ with pytest.raises(TruncatedDataError, match="4 bytes at offset 1"):
+ reader.read_uint32()
diff --git a/tests/unit/sampletones_core/generators/implementation/test_noise.py b/tests/unit/sampletones_core/generators/implementation/test_noise.py
index ae230967e..4da629faf 100644
--- a/tests/unit/sampletones_core/generators/implementation/test_noise.py
+++ b/tests/unit/sampletones_core/generators/implementation/test_noise.py
@@ -2,7 +2,7 @@
import pytest
from sampletones_core.configs import Config
-from sampletones_core.constants.enums import GeneratorClassName, GeneratorName
+from sampletones_core.constants.enums import ChannelName, GeneratorClassName
from sampletones_core.constants.general import (
MAX_VOLUME,
MIXER_NOISE,
@@ -30,12 +30,12 @@ def config() -> Config:
@pytest.fixture
def generator(config: Config) -> NoiseGenerator:
- return NoiseGenerator(config, GeneratorName.NOISE)
+ return NoiseGenerator(config, ChannelName.NOISE)
@pytest.fixture
def pulse_generator(config: Config) -> PulseGenerator:
- return PulseGenerator(config, GeneratorName.PULSE1)
+ return PulseGenerator(config, ChannelName.PULSE1)
class TestNoiseGeneratorCall:
diff --git a/tests/unit/sampletones_core/generators/implementation/test_pulse.py b/tests/unit/sampletones_core/generators/implementation/test_pulse.py
index c096bfc4e..a879ee71c 100644
--- a/tests/unit/sampletones_core/generators/implementation/test_pulse.py
+++ b/tests/unit/sampletones_core/generators/implementation/test_pulse.py
@@ -2,7 +2,7 @@
import pytest
from sampletones_core.configs import Config
-from sampletones_core.constants.enums import GeneratorClassName, GeneratorName
+from sampletones_core.constants.enums import ChannelName, GeneratorClassName
from sampletones_core.constants.general import DUTY_CYCLES, MAX_VOLUME, MIXER_PULSE
from sampletones_core.generators.implementation.pulse import PulseGenerator
from sampletones_core.instructions import NoiseInstruction, PulseInstruction
@@ -19,7 +19,7 @@ def config() -> Config:
@pytest.fixture
def generator(config: Config) -> PulseGenerator:
- return PulseGenerator(config, GeneratorName.PULSE1)
+ return PulseGenerator(config, ChannelName.PULSE1)
class TestPulseGeneratorCall:
diff --git a/tests/unit/sampletones_core/generators/implementation/test_triangle.py b/tests/unit/sampletones_core/generators/implementation/test_triangle.py
index d0dc6419b..e51fb15f0 100644
--- a/tests/unit/sampletones_core/generators/implementation/test_triangle.py
+++ b/tests/unit/sampletones_core/generators/implementation/test_triangle.py
@@ -2,7 +2,7 @@
import pytest
from sampletones_core.configs import Config
-from sampletones_core.constants.enums import GeneratorClassName, GeneratorName
+from sampletones_core.constants.enums import ChannelName, GeneratorClassName
from sampletones_core.constants.general import MIXER_TRIANGLE
from sampletones_core.generators.implementation.triangle import TriangleGenerator
from sampletones_core.instructions import PulseInstruction, TriangleInstruction
@@ -19,7 +19,7 @@ def config() -> Config:
@pytest.fixture
def generator(config: Config) -> TriangleGenerator:
- return TriangleGenerator(config, GeneratorName.TRIANGLE)
+ return TriangleGenerator(config, ChannelName.TRIANGLE)
class TestTriangleGeneratorCall:
diff --git a/tests/unit/sampletones_core/generators/test_generator.py b/tests/unit/sampletones_core/generators/test_generator.py
index 94a418b71..64493a5b6 100644
--- a/tests/unit/sampletones_core/generators/test_generator.py
+++ b/tests/unit/sampletones_core/generators/test_generator.py
@@ -3,7 +3,7 @@
from sampletones_core.configs import Config
from sampletones_core.constants.algorithm import MIN_SAMPLE_LENGTH
-from sampletones_core.constants.enums import GeneratorName
+from sampletones_core.constants.enums import ChannelName
from sampletones_core.fft import CyclicArray
from sampletones_core.generators.implementation.pulse import PulseGenerator
from sampletones_core.instructions import PulseInstruction
@@ -16,13 +16,13 @@ def config() -> Config:
@pytest.fixture
def generator(config: Config) -> PulseGenerator:
- return PulseGenerator(config, GeneratorName.PULSE1)
+ return PulseGenerator(config, ChannelName.PULSE1)
class TestGeneratorInit:
def test_non_config_raises(self) -> None:
with pytest.raises(TypeError):
- PulseGenerator("not_a_config", GeneratorName.PULSE1)
+ PulseGenerator("not_a_config", ChannelName.PULSE1)
def test_non_str_name_raises(self, config: Config) -> None:
with pytest.raises(TypeError):
diff --git a/tests/unit/sampletones_core/generators/test_render.py b/tests/unit/sampletones_core/generators/test_render.py
new file mode 100644
index 000000000..f4c33a273
--- /dev/null
+++ b/tests/unit/sampletones_core/generators/test_render.py
@@ -0,0 +1,107 @@
+from typing import Dict, List
+
+import numpy as np
+import pytest
+
+from sampletones_core.configs import Config
+from sampletones_core.constants.enums import ChannelName
+from sampletones_core.generators.render import render_channels, render_instructions
+from sampletones_core.instructions import (
+ InstructionUnion,
+ NoiseInstruction,
+ PulseInstruction,
+ TriangleInstruction,
+)
+
+
+@pytest.fixture
+def config() -> Config:
+ return Config()
+
+
+@pytest.fixture
+def pulse_stream() -> List[InstructionUnion]:
+ return [
+ PulseInstruction(on=True, pitch=60, volume=15, duty_cycle=0),
+ PulseInstruction(on=True, pitch=60, volume=15, duty_cycle=0),
+ PulseInstruction(on=False, pitch=60, volume=0, duty_cycle=0),
+ ]
+
+
+class TestRenderInstructions:
+ """One channel's audio, rendered frame by frame from the instructions that drive it."""
+
+ def test_every_instruction_renders_one_frame(
+ self,
+ pulse_stream: List[InstructionUnion],
+ config: Config,
+ ) -> None:
+ rendered = render_instructions(pulse_stream, ChannelName.PULSE1, config)
+ assert len(rendered) == len(pulse_stream) * config.library.frame_length
+
+ def test_a_held_note_runs_its_oscillator_on(
+ self,
+ pulse_stream: List[InstructionUnion],
+ config: Config,
+ ) -> None:
+ frame_length = config.library.frame_length
+ rendered = render_instructions(pulse_stream, ChannelName.PULSE1, config)
+ first = rendered[:frame_length]
+ second = rendered[frame_length : 2 * frame_length]
+ assert not np.array_equal(first, second)
+
+ def test_a_rest_renders_silence(self, pulse_stream: List[InstructionUnion], config: Config) -> None:
+ frame_length = config.library.frame_length
+ rendered = render_instructions(pulse_stream, ChannelName.PULSE1, config)
+ assert not np.any(rendered[2 * frame_length :])
+
+ def test_a_channel_describing_no_frame_raises(self, config: Config) -> None:
+ with pytest.raises(ValueError):
+ render_instructions([], ChannelName.PULSE1, config)
+
+
+class TestRenderGenerators:
+ """The audio of every channel that describes a frame, in channel order."""
+
+ @pytest.fixture
+ def streams(self, pulse_stream: List[InstructionUnion]) -> Dict[ChannelName, List[InstructionUnion]]:
+ return {
+ ChannelName.PULSE1: pulse_stream,
+ ChannelName.TRIANGLE: [TriangleInstruction(on=True, pitch=48)],
+ ChannelName.NOISE: [NoiseInstruction(on=True, period=4, volume=15, short=False)],
+ }
+
+ def test_every_sounding_channel_renders(
+ self,
+ streams: Dict[ChannelName, List[InstructionUnion]],
+ config: Config,
+ ) -> None:
+ assert set(render_channels(streams, config)) == set(streams)
+
+ def test_a_channel_standing_by_renders_nothing(
+ self,
+ streams: Dict[ChannelName, List[InstructionUnion]],
+ config: Config,
+ ) -> None:
+ streams[ChannelName.PULSE2] = []
+ assert ChannelName.PULSE2 not in render_channels(streams, config)
+
+ def test_the_channels_come_back_in_channel_order(
+ self,
+ streams: Dict[ChannelName, List[InstructionUnion]],
+ config: Config,
+ ) -> None:
+ rendered = render_channels(streams, config)
+ assert list(rendered) == [name for name in ChannelName.items() if name in streams]
+
+ def test_a_channel_sounds_what_it_renders_on_its_own(
+ self,
+ streams: Dict[ChannelName, List[InstructionUnion]],
+ config: Config,
+ ) -> None:
+ rendered = render_channels(streams, config)
+ for channel_name, instructions in streams.items():
+ assert np.array_equal(
+ rendered[channel_name],
+ render_instructions(instructions, channel_name, config),
+ )
diff --git a/tests/unit/sampletones_core/generators/test_tonal.py b/tests/unit/sampletones_core/generators/test_tonal.py
new file mode 100644
index 000000000..4a505a85b
--- /dev/null
+++ b/tests/unit/sampletones_core/generators/test_tonal.py
@@ -0,0 +1,104 @@
+import math
+from typing import Final
+
+import pytest
+
+from sampletones_core.configs import Config
+from sampletones_core.constants.enums import ChannelName
+from sampletones_core.constants.general import (
+ HI_PITCH_FACTOR,
+ MAX_PITCH,
+ MAX_TIMER,
+ MIN_TIMER,
+)
+from sampletones_core.generators.implementation.pulse import PulseGenerator
+from sampletones_core.generators.implementation.triangle import TriangleGenerator
+from sampletones_core.instructions import PulseInstruction, TriangleInstruction
+
+PITCH: Final[int] = 60
+VOLUME: Final[int] = 15
+CENTS_PER_OCTAVE: Final[float] = 1200.0
+
+
+@pytest.fixture
+def config() -> Config:
+ return Config()
+
+
+@pytest.fixture
+def generator(config: Config) -> PulseGenerator:
+ return PulseGenerator(config, ChannelName.PULSE1)
+
+
+def _pulse(**bend: int) -> PulseInstruction:
+ return PulseInstruction(on=True, pitch=PITCH, volume=VOLUME, duty_cycle=0, **bend)
+
+
+class TestBendingTheDivider:
+ def test_an_unbent_frame_sounds_at_the_note_itself(self, generator: PulseGenerator) -> None:
+ generator.set_timer(_pulse())
+
+ assert generator.timer.timer == generator.timer_table[PITCH]
+
+ def test_a_bend_moves_the_divider_by_the_steps_it_states(self, generator: PulseGenerator) -> None:
+ generator.set_timer(_pulse(detune=5, coarse_detune=1))
+
+ assert generator.timer.timer == generator.timer_table[PITCH] + 5 + HI_PITCH_FACTOR
+
+ def test_a_larger_divider_sounds_lower(self, generator: PulseGenerator) -> None:
+ """The divider counts a period, so adding to it lowers the note the frame sounds."""
+ generator.set_timer(_pulse())
+ unbent = generator.timer.frequency
+
+ generator.set_timer(_pulse(detune=1))
+
+ assert generator.timer.frequency < unbent
+
+ def test_a_bend_of_a_whole_gap_reaches_the_neighboring_note(self, generator: PulseGenerator) -> None:
+ gap = generator.timer_table[PITCH] - generator.timer_table[PITCH + 1]
+ generator.set_timer(_pulse(detune=-gap))
+
+ assert generator.timer.timer == generator.timer_table[PITCH + 1]
+
+ def test_one_step_is_finer_than_a_semitone_in_the_middle_register(
+ self,
+ generator: PulseGenerator,
+ ) -> None:
+ """A step spans a fraction of the gap to the next note, which is the room a bend has."""
+ generator.set_timer(_pulse())
+ unbent = generator.timer.frequency
+ generator.set_timer(_pulse(detune=1))
+ stepped = generator.timer.frequency
+
+ cents = abs(CENTS_PER_OCTAVE * math.log2(stepped / unbent))
+ semitone = CENTS_PER_OCTAVE / 12
+
+ assert 0.0 < cents < semitone
+
+
+class TestBendClamping:
+ def test_a_bend_below_the_register_is_held_at_its_floor(self, generator: PulseGenerator) -> None:
+ generator.set_timer(PulseInstruction(on=True, pitch=MAX_PITCH, volume=VOLUME, duty_cycle=0, coarse_detune=-8))
+
+ assert generator.timer.timer == MIN_TIMER
+
+ def test_a_bend_past_the_register_is_held_at_its_ceiling(self, config: Config) -> None:
+ generator = PulseGenerator(config, ChannelName.PULSE1)
+ lowest = min(generator.timer_table)
+ generator.set_timer(PulseInstruction(on=True, pitch=lowest, volume=VOLUME, duty_cycle=0, coarse_detune=127))
+
+ assert generator.timer.timer == MAX_TIMER
+
+ def test_a_silent_frame_carries_no_bend_into_the_timer(self, generator: PulseGenerator) -> None:
+ generator.set_timer(PulseInstruction(on=False, pitch=PITCH, volume=0, duty_cycle=0, detune=40))
+
+ assert generator.timer.frequency == 0.0 or generator.timer.timer == 0
+
+
+class TestTriangleBend:
+ def test_the_triangle_bends_through_the_same_table(self, config: Config) -> None:
+ generator = TriangleGenerator(config, ChannelName.TRIANGLE)
+
+ generator.set_timer(TriangleInstruction(on=True, pitch=PITCH, detune=-3))
+
+ assert generator.timer.timer == generator.timer_table[PITCH] - 3
diff --git a/tests/unit/sampletones_core/generators/test_utils.py b/tests/unit/sampletones_core/generators/test_utils.py
index e30432696..4f40e8fa6 100644
--- a/tests/unit/sampletones_core/generators/test_utils.py
+++ b/tests/unit/sampletones_core/generators/test_utils.py
@@ -3,13 +3,13 @@
import pytest
from sampletones_core.configs import Config
-from sampletones_core.constants.enums import GeneratorClassName, GeneratorName
+from sampletones_core.constants.enums import ChannelName, GeneratorClassName
from sampletones_core.generators.implementation.noise import NoiseGenerator
from sampletones_core.generators.implementation.pulse import PulseGenerator
from sampletones_core.generators.implementation.triangle import TriangleGenerator
from sampletones_core.generators.utils import (
get_generator_by_instruction,
- get_generators_by_names,
+ get_generators_by_channels,
get_generators_map,
get_remaining_generator_classes,
)
@@ -28,27 +28,27 @@ def config() -> Config:
@pytest.fixture
def all_generators(config: Config) -> dict:
return {
- GeneratorClassName.PULSE_GENERATOR: PulseGenerator(config, GeneratorName.PULSE1),
- GeneratorClassName.TRIANGLE_GENERATOR: TriangleGenerator(config, GeneratorName.TRIANGLE),
- GeneratorClassName.NOISE_GENERATOR: NoiseGenerator(config, GeneratorName.NOISE),
+ GeneratorClassName.PULSE_GENERATOR: PulseGenerator(config, ChannelName.PULSE1),
+ GeneratorClassName.TRIANGLE_GENERATOR: TriangleGenerator(config, ChannelName.TRIANGLE),
+ GeneratorClassName.NOISE_GENERATOR: NoiseGenerator(config, ChannelName.NOISE),
}
-class TestGetGeneratorsByNames:
+class TestGetGeneratorsByChannels:
def test_pulse1_returns_pulse1(self, config: Config) -> None:
- result = get_generators_by_names(config, [GeneratorName.PULSE1])
- assert GeneratorName.PULSE1 in result
- assert isinstance(result[GeneratorName.PULSE1], PulseGenerator)
+ result = get_generators_by_channels(config, [ChannelName.PULSE1])
+ assert ChannelName.PULSE1 in result
+ assert isinstance(result[ChannelName.PULSE1], PulseGenerator)
def test_pulse2_without_pulse1_is_replaced_by_pulse1(self, config: Config) -> None:
- result = get_generators_by_names(config, [GeneratorName.PULSE2])
- assert GeneratorName.PULSE1 in result
- assert GeneratorName.PULSE2 not in result
+ result = get_generators_by_channels(config, [ChannelName.PULSE2])
+ assert ChannelName.PULSE1 in result
+ assert ChannelName.PULSE2 not in result
def test_multiple_names_all_returned(self, config: Config) -> None:
- result = get_generators_by_names(config, [GeneratorName.PULSE1, GeneratorName.TRIANGLE])
- assert GeneratorName.PULSE1 in result
- assert GeneratorName.TRIANGLE in result
+ result = get_generators_by_channels(config, [ChannelName.PULSE1, ChannelName.TRIANGLE])
+ assert ChannelName.PULSE1 in result
+ assert ChannelName.TRIANGLE in result
class TestGetGeneratorsMap:
@@ -68,13 +68,38 @@ def test_generators_are_correct_types(self, config: Config) -> None:
class TestGetRemainingGeneratorClasses:
def test_maps_by_class_name(self, config: Config) -> None:
named = {
- GeneratorName.PULSE1: PulseGenerator(config, GeneratorName.PULSE1),
- GeneratorName.NOISE: NoiseGenerator(config, GeneratorName.NOISE),
+ ChannelName.PULSE1: PulseGenerator(config, ChannelName.PULSE1),
+ ChannelName.NOISE: NoiseGenerator(config, ChannelName.NOISE),
}
result = get_remaining_generator_classes(named)
assert GeneratorClassName.PULSE_GENERATOR in result
assert GeneratorClassName.NOISE_GENERATOR in result
+ def test_lowest_pulse_channel_is_representative_when_both_remain(self, config: Config) -> None:
+ named = {
+ ChannelName.PULSE2: PulseGenerator(config, ChannelName.PULSE2),
+ ChannelName.PULSE1: PulseGenerator(config, ChannelName.PULSE1),
+ }
+ result = get_remaining_generator_classes(named)
+ assert result[GeneratorClassName.PULSE_GENERATOR] is named[ChannelName.PULSE1]
+
+ def test_pulse2_represents_pulse_kind_after_pulse1_is_consumed(self, config: Config) -> None:
+ named = {
+ ChannelName.PULSE2: PulseGenerator(config, ChannelName.PULSE2),
+ }
+ result = get_remaining_generator_classes(named)
+ assert result[GeneratorClassName.PULSE_GENERATOR] is named[ChannelName.PULSE2]
+
+ def test_single_channel_kinds_keep_their_own_generator(self, config: Config) -> None:
+ named = {
+ ChannelName.PULSE1: PulseGenerator(config, ChannelName.PULSE1),
+ ChannelName.TRIANGLE: TriangleGenerator(config, ChannelName.TRIANGLE),
+ ChannelName.NOISE: NoiseGenerator(config, ChannelName.NOISE),
+ }
+ result = get_remaining_generator_classes(named)
+ assert result[GeneratorClassName.TRIANGLE_GENERATOR] is named[ChannelName.TRIANGLE]
+ assert result[GeneratorClassName.NOISE_GENERATOR] is named[ChannelName.NOISE]
+
class TestGetGeneratorByInstruction:
def test_pulse_instruction_returns_pulse_generator(self, all_generators: Dict) -> None:
diff --git a/tests/unit/sampletones_core/instructions/test_tonal.py b/tests/unit/sampletones_core/instructions/test_tonal.py
new file mode 100644
index 000000000..7d94d9adc
--- /dev/null
+++ b/tests/unit/sampletones_core/instructions/test_tonal.py
@@ -0,0 +1,59 @@
+from dataclasses import dataclass
+from typing import Final, Tuple
+
+import pytest
+from pydantic import ValidationError
+
+from sampletones_core.constants.general import (
+ HI_PITCH_FACTOR,
+ PITCH_BEND_MAX,
+ PITCH_BEND_MIN,
+)
+from sampletones_core.instructions import PulseInstruction, TriangleInstruction
+from tests.suite.base import BaseTestSuite
+from tests.suite.case import BaseRegularTestCase
+
+PITCH: Final[int] = 60
+VOLUME: Final[int] = 15
+
+
+def _pulse(**bend: int) -> PulseInstruction:
+ return PulseInstruction(on=True, pitch=PITCH, volume=VOLUME, duty_cycle=0, **bend)
+
+
+class TestTimerOffset(BaseTestSuite):
+ """The two bend dimensions read as one offset, the coarse one counting sixteen steps."""
+
+ @dataclass(frozen=True, kw_only=True)
+ class TestCase(BaseRegularTestCase):
+ detune: int
+ coarse_detune: int
+ expected: int
+
+ test_cases: Tuple["TestTimerOffset.TestCase", ...] = (
+ TestCase(label="no bend", detune=0, coarse_detune=0, expected=0),
+ TestCase(label="fine alone", detune=7, coarse_detune=0, expected=7),
+ TestCase(label="coarse alone", detune=0, coarse_detune=3, expected=3 * HI_PITCH_FACTOR),
+ TestCase(label="both", detune=-4, coarse_detune=-1, expected=-4 - HI_PITCH_FACTOR),
+ TestCase(label="opposing", detune=-1, coarse_detune=1, expected=HI_PITCH_FACTOR - 1),
+ )
+
+ @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label)
+ def test_the_offset_sums_both_dimensions(self, test_case: "TestTimerOffset.TestCase") -> None:
+ instruction = _pulse(detune=test_case.detune, coarse_detune=test_case.coarse_detune)
+
+ assert instruction.timer_offset == test_case.expected
+ assert instruction.bent == (test_case.expected != 0)
+
+
+class TestBendDefaults:
+ def test_a_frame_states_no_bend_unless_it_names_one(self) -> None:
+ assert _pulse().timer_offset == 0
+ assert TriangleInstruction(on=True, pitch=PITCH).timer_offset == 0
+
+ def test_a_bend_past_the_range_a_sequence_stores_is_refused(self) -> None:
+ with pytest.raises(ValidationError):
+ _pulse(detune=PITCH_BEND_MAX + 1)
+
+ with pytest.raises(ValidationError):
+ _pulse(coarse_detune=PITCH_BEND_MIN - 1)
diff --git a/tests/unit/sampletones_core/parallelization/__init__.py b/tests/unit/sampletones_core/parallelization/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/tests/unit/sampletones_core/parallelization/test_steps.py b/tests/unit/sampletones_core/parallelization/test_steps.py
new file mode 100644
index 000000000..cbe24f396
--- /dev/null
+++ b/tests/unit/sampletones_core/parallelization/test_steps.py
@@ -0,0 +1,120 @@
+from dataclasses import dataclass
+from typing import Final, Tuple
+
+import pytest
+
+from sampletones_core.parallelization.steps import TaskSteps
+from sampletones_core.parallelization.task import TaskProgress, TaskReport, TaskStep
+from tests.suite.base import BaseTestSuite
+from tests.suite.case import BaseRegularTestCase
+
+STAGE: Final[str] = "matching"
+TOTAL: Final[int] = 4
+FIRST: Final[int] = 0
+SECOND: Final[int] = 1
+THIRD: Final[int] = 2
+
+
+def _report(index: int, completed: int) -> TaskReport:
+ return TaskReport(
+ index=index,
+ step=TaskStep(stage=STAGE, completed=completed, total=TOTAL, fraction=completed / TOTAL),
+ )
+
+
+class TestWhereTheRunningTasksStand(BaseTestSuite):
+ """The table holds one step per running task, in the order the run handed the tasks out."""
+
+ def test_a_task_reporting_twice_stands_where_it_last_said(self) -> None:
+ steps = TaskSteps()
+
+ steps.record(_report(FIRST, 1))
+ steps.record(_report(FIRST, 3))
+
+ assert steps.snapshot() == (_report(FIRST, 3).step,)
+
+ def test_the_snapshot_follows_the_order_the_tasks_were_handed_out(self) -> None:
+ steps = TaskSteps()
+
+ steps.record(_report(THIRD, 1))
+ steps.record(_report(FIRST, 2))
+ steps.record(_report(SECOND, 3))
+
+ assert steps.snapshot() == (
+ _report(FIRST, 2).step,
+ _report(SECOND, 3).step,
+ _report(THIRD, 1).step,
+ )
+
+ def test_a_finished_task_is_let_go_of(self) -> None:
+ steps = TaskSteps()
+ steps.record(_report(FIRST, 2))
+ steps.record(_report(SECOND, 1))
+
+ steps.complete(FIRST)
+
+ assert steps.snapshot() == (_report(SECOND, 1).step,)
+
+ def test_a_report_arriving_after_its_task_finished_is_history(self) -> None:
+ steps = TaskSteps()
+ steps.record(_report(FIRST, 2))
+
+ steps.complete(FIRST)
+ steps.record(_report(FIRST, 4))
+
+ assert steps.snapshot() == ()
+
+ def test_a_task_finishing_before_it_ever_reported_costs_nothing(self) -> None:
+ steps = TaskSteps()
+
+ steps.complete(SECOND)
+
+ assert steps.snapshot() == ()
+
+ def test_a_cleared_table_takes_a_new_run_as_it_comes(self) -> None:
+ steps = TaskSteps()
+ steps.record(_report(FIRST, 2))
+ steps.complete(FIRST)
+
+ steps.clear()
+ steps.record(_report(FIRST, 1))
+
+ assert steps.snapshot() == (_report(FIRST, 1).step,)
+
+
+class TestHowFullARunStands(BaseTestSuite):
+ """A run reads as the tasks it finished plus the part of each running task that is done."""
+
+ @dataclass(frozen=True, kw_only=True)
+ class TestCase(BaseRegularTestCase):
+ expected: float
+ total: int
+ completed: int
+ fractions: Tuple[float, ...]
+
+ test_cases = (
+ TestCase(label="a_run_with_nothing_to_do", total=0, completed=0, fractions=(), expected=0.0),
+ TestCase(label="one_task_untouched", total=1, completed=0, fractions=(), expected=0.0),
+ TestCase(label="one_task_partway", total=1, completed=0, fractions=(0.25,), expected=0.25),
+ TestCase(label="one_task_done", total=1, completed=1, fractions=(), expected=1.0),
+ TestCase(label="two_of_four_and_one_partway", total=4, completed=2, fractions=(0.5,), expected=0.625),
+ TestCase(label="two_tasks_partway", total=4, completed=1, fractions=(0.5, 0.25), expected=0.4375),
+ )
+
+ @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label)
+ def test_the_fraction_counts_the_work_under_way(self, test_case: TestCase) -> None:
+ progress = TaskProgress(
+ total=test_case.total,
+ completed=test_case.completed,
+ steps=tuple(
+ TaskStep(stage=STAGE, completed=1, total=TOTAL, fraction=fraction) for fraction in test_case.fractions
+ ),
+ )
+
+ assert progress.fraction == pytest.approx(test_case.expected)
+
+ def test_a_run_reporting_no_steps_reads_as_its_completed_tasks(self) -> None:
+ progress = TaskProgress(total=4, completed=3)
+
+ assert progress.partial == 0.0
+ assert progress.fraction == pytest.approx(0.75)
diff --git a/tests/unit/sampletones_core/performance/__init__.py b/tests/unit/sampletones_core/performance/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/tests/unit/sampletones_core/performance/test_audition.py b/tests/unit/sampletones_core/performance/test_audition.py
new file mode 100644
index 000000000..b8bfd3e41
--- /dev/null
+++ b/tests/unit/sampletones_core/performance/test_audition.py
@@ -0,0 +1,227 @@
+from typing import Final
+
+import numpy as np
+import pytest
+
+from sampletones_core.configs import Config
+from sampletones_core.constants.enums import ChannelName
+from sampletones_core.features.envelope import Envelope
+from sampletones_core.performance.audition import (
+ audition_audio,
+ audition_instructions,
+ audition_ticks,
+)
+from sampletones_core.project.voices.envelopes import InstrumentEnvelopes
+from sampletones_core.project.voices.instrument import Instrument
+
+REFERENCE_PITCH: Final[int] = 60
+REFERENCE_PERIOD: Final[int] = 8
+TYPED_PITCH: Final[int] = 67
+NES_FREQUENCY: Final[int] = 60
+SAMPLE_RATE: Final[int] = 44100
+WRITTEN_TICKS: Final[int] = 3
+CAP: Final[int] = 24
+
+
+def _instrument() -> Instrument:
+ """A voice with a value in every dimension, so each one shows in the frames it makes."""
+ return Instrument(
+ name="lead",
+ envelopes=InstrumentEnvelopes(
+ volume=Envelope[int](items=(15, 12, 9)),
+ arpeggio=Envelope[int](items=(0, 4, 7)),
+ duty_cycle=Envelope[int](items=(2,)),
+ ),
+ initial_pitch=REFERENCE_PITCH,
+ initial_period=REFERENCE_PERIOD,
+ )
+
+
+def _config() -> Config:
+ return Config().with_library(
+ nes_frequency=NES_FREQUENCY,
+ sample_rate=SAMPLE_RATE,
+ )
+
+
+class TestTheNoteAnAuditionSounds:
+ def test_the_reference_cancels_so_a_voice_sounds_at_the_note_asked_for(self) -> None:
+ instructions = audition_instructions(
+ _instrument(),
+ ChannelName.PULSE1,
+ pitch=TYPED_PITCH,
+ ticks=WRITTEN_TICKS,
+ )
+
+ assert [instruction.pitch for instruction in instructions] == [
+ TYPED_PITCH,
+ TYPED_PITCH + 4,
+ TYPED_PITCH + 7,
+ ]
+
+ def test_sounding_at_its_own_reference_leaves_the_frames_as_they_stand(self) -> None:
+ instrument = _instrument()
+ instructions = audition_instructions(
+ instrument,
+ ChannelName.PULSE1,
+ pitch=REFERENCE_PITCH,
+ ticks=WRITTEN_TICKS,
+ )
+
+ assert instructions == instrument.instructions(ChannelName.PULSE1)
+
+ def test_a_voice_sounds_at_full_volume_however_loud_its_envelope_is(self) -> None:
+ instructions = audition_instructions(
+ _instrument(),
+ ChannelName.PULSE1,
+ pitch=TYPED_PITCH,
+ ticks=WRITTEN_TICKS,
+ )
+
+ assert [instruction.volume for instruction in instructions] == [15, 12, 9]
+
+ def test_the_noise_channel_walks_the_periods_its_arpeggio_names(self) -> None:
+ instructions = audition_instructions(
+ _instrument(),
+ ChannelName.NOISE,
+ pitch=REFERENCE_PERIOD,
+ ticks=WRITTEN_TICKS,
+ )
+
+ assert [instruction.period for instruction in instructions] == [8, 12, 15]
+
+ def test_a_channel_reads_the_dimensions_its_generator_offers(self) -> None:
+ instructions = audition_instructions(
+ _instrument(),
+ ChannelName.TRIANGLE,
+ pitch=TYPED_PITCH,
+ ticks=WRITTEN_TICKS,
+ )
+
+ assert all(instruction.on for instruction in instructions)
+
+
+class TestHowLongAnAuditionSounds:
+ """A voice sounded on its own runs to the release it states, or to the length it is offered."""
+
+ def test_a_voice_whose_volume_ends_in_silence_stops_where_it_releases(self) -> None:
+ """The dimension holds its last item forever, so a final zero is where the note ends."""
+ instrument = Instrument(
+ name="decay",
+ envelopes=InstrumentEnvelopes(volume=Envelope[int](items=(15, 9, 3, 0))),
+ )
+
+ assert audition_ticks(instrument, cap=CAP) == 4
+
+ def test_a_voice_that_sustains_sounds_for_the_length_it_is_offered(self) -> None:
+ """A dimension circling from a loop point never reaches a last item, so nothing ends it."""
+ instrument = Instrument(
+ name="sustain",
+ envelopes=InstrumentEnvelopes(volume=Envelope[int](items=(15,), loop_point=0)),
+ )
+
+ assert audition_ticks(instrument, cap=CAP) == CAP
+
+ def test_a_volume_circling_back_through_silence_still_sounds_on(self) -> None:
+ """A zero the dimension loops past is a silent tick, and the note goes on past it."""
+ instrument = Instrument(
+ name="pulsing",
+ envelopes=InstrumentEnvelopes(volume=Envelope[int](items=(15, 0), loop_point=0)),
+ )
+
+ assert audition_ticks(instrument, cap=CAP) == CAP
+
+ def test_a_voice_leaving_its_volume_to_the_channel_sounds_for_the_length_offered(self) -> None:
+ instrument = Instrument(
+ name="held",
+ envelopes=InstrumentEnvelopes(arpeggio=Envelope[int](items=(0, 4))),
+ )
+
+ assert audition_ticks(instrument, cap=CAP) == CAP
+
+
+class TestTheFramesAnAuditionSoundsPastWhatIsWritten:
+ """A voice sounds past its written frames the way a held tracker row sounds it."""
+
+ def test_a_dimension_holds_its_last_item_once_the_written_frames_run_out(self) -> None:
+ instrument = _instrument()
+
+ instructions = audition_instructions(
+ instrument,
+ ChannelName.PULSE1,
+ pitch=REFERENCE_PITCH,
+ ticks=WRITTEN_TICKS + 2,
+ )
+
+ assert [instruction.volume for instruction in instructions] == [15, 12, 9, 9, 9]
+
+ def test_a_dimension_circles_from_the_item_it_repeats_from(self) -> None:
+ instrument = Instrument(
+ name="looping",
+ envelopes=InstrumentEnvelopes(volume=Envelope[int](items=(15, 12, 9), loop_point=1)),
+ )
+
+ instructions = audition_instructions(
+ instrument,
+ ChannelName.PULSE1,
+ pitch=REFERENCE_PITCH,
+ ticks=6,
+ )
+
+ assert [instruction.volume for instruction in instructions] == [15, 12, 9, 12, 9, 12]
+
+
+class TestTheAudioAnAuditionPlays:
+ @pytest.mark.parametrize(
+ "channel_name",
+ list(ChannelName.items()),
+ ids=[channel_name.value for channel_name in ChannelName.items()],
+ )
+ def test_a_voice_renders_one_frame_per_tick_it_is_sounded_for(
+ self,
+ channel_name: ChannelName,
+ ) -> None:
+ config = _config()
+ audio = audition_audio(
+ _instrument(),
+ channel_name,
+ config,
+ pitch=TYPED_PITCH,
+ ticks=CAP,
+ )
+
+ assert audio is not None
+ assert audio.shape == (CAP * config.frame_length,)
+
+ def test_a_voice_writing_no_envelope_sounds_nothing(self) -> None:
+ assert (
+ audition_audio(
+ Instrument(name="silent"),
+ ChannelName.PULSE1,
+ _config(),
+ pitch=TYPED_PITCH,
+ ticks=CAP,
+ )
+ is None
+ )
+
+ def test_two_notes_of_one_voice_render_to_different_audio(self) -> None:
+ config = _config()
+ instrument = _instrument()
+ low = audition_audio(
+ instrument,
+ ChannelName.PULSE1,
+ config,
+ pitch=REFERENCE_PITCH,
+ ticks=WRITTEN_TICKS,
+ )
+ high = audition_audio(
+ instrument,
+ ChannelName.PULSE1,
+ config,
+ pitch=TYPED_PITCH,
+ ticks=WRITTEN_TICKS,
+ )
+
+ assert low is not None and high is not None
+ assert not np.array_equal(low, high)
diff --git a/tests/unit/sampletones_core/performance/test_bend.py b/tests/unit/sampletones_core/performance/test_bend.py
new file mode 100644
index 000000000..467eec68e
--- /dev/null
+++ b/tests/unit/sampletones_core/performance/test_bend.py
@@ -0,0 +1,117 @@
+from typing import Final, List
+
+import pytest
+
+from sampletones_core.constants.enums import ChannelName, FeatureKey
+from sampletones_core.constants.general import MAX_VOLUME
+from sampletones_core.features.envelope import Envelope
+from sampletones_core.instructions import InstructionUnion, PulseInstruction
+from sampletones_core.performance import (
+ ChannelPerformance,
+ VoiceReading,
+ apply_modifiers,
+ sound_tick,
+)
+from sampletones_core.project.voices.envelopes import InstrumentEnvelopes
+from sampletones_core.project.voices.instrument import Instrument
+
+REFERENCE: Final[int] = 60
+SEMITONE: Final[int] = 1
+BEND: Final[int] = 5
+COARSE_BEND: Final[int] = 2
+
+
+def _instrument(**envelopes: Envelope[int]) -> Instrument:
+ return Instrument(
+ name="bent",
+ envelopes=InstrumentEnvelopes(volume=Envelope(items=(MAX_VOLUME, MAX_VOLUME)), **envelopes),
+ initial_pitch=REFERENCE,
+ )
+
+
+def _played(instrument: Instrument, ticks: int) -> List[InstructionUnion]:
+ """The frames one channel sounds, tick by tick, with the channel's own values filled in."""
+ reading = VoiceReading.read(instrument, ChannelName.PULSE1)
+ assert reading is not None
+
+ performance = ChannelPerformance(voice_id=instrument.id)
+ played: List[InstructionUnion] = []
+ for _ in range(ticks):
+ sounded = sound_tick(performance, reading)
+ assert sounded is not None
+ played.append(sounded)
+
+ return played
+
+
+class TestABendThroughTheSongWalk:
+ def test_the_bend_an_envelope_writes_reaches_the_frame_a_tick_sounds(self) -> None:
+ played = _played(_instrument(pitch=Envelope(items=(BEND, -BEND))), 2)
+
+ assert [frame.detune for frame in played] == [BEND, -BEND]
+
+ def test_both_dimensions_reach_the_frame_together(self) -> None:
+ played = _played(
+ _instrument(
+ pitch=Envelope(items=(BEND,)),
+ hi_pitch=Envelope(items=(COARSE_BEND,)),
+ ),
+ 1,
+ )
+
+ assert played[0].detune == BEND
+ assert played[0].coarse_detune == COARSE_BEND
+
+ def test_a_channel_holds_the_bend_a_voice_left_it(self) -> None:
+ """A voice writing no bend sounds at what the channel holds, which is what the last one set."""
+ reading = VoiceReading.read(_instrument(), ChannelName.PULSE1)
+ assert reading is not None
+
+ performance = ChannelPerformance(voice_id="held")
+ performance.feature_values[FeatureKey.PITCH] = BEND
+ sounded = sound_tick(performance, reading)
+
+ assert sounded is not None
+ assert sounded.detune == BEND
+
+ def test_a_bend_a_voice_writes_becomes_what_the_channel_holds(self) -> None:
+ reading = VoiceReading.read(_instrument(pitch=Envelope(items=(BEND,))), ChannelName.PULSE1)
+ assert reading is not None
+
+ performance = ChannelPerformance(voice_id="written")
+ sound_tick(performance, reading)
+
+ assert performance.feature_values[FeatureKey.PITCH] == BEND
+
+
+class TestABendUnderARowsModifiers:
+ @pytest.mark.parametrize("transpose", (-SEMITONE, 0, SEMITONE), ids=("down", "none", "up"))
+ def test_a_transpose_moves_the_note_and_leaves_the_bend_where_it_is(self, transpose: int) -> None:
+ """A row states the note; how far off that note the frame sounds is the frame's own."""
+ instruction = PulseInstruction(
+ on=True,
+ pitch=REFERENCE,
+ volume=MAX_VOLUME,
+ duty_cycle=0,
+ detune=BEND,
+ coarse_detune=COARSE_BEND,
+ )
+
+ sounded = apply_modifiers(instruction, transpose, MAX_VOLUME)
+
+ assert sounded.pitch == REFERENCE + transpose
+ assert sounded.detune == BEND
+ assert sounded.coarse_detune == COARSE_BEND
+
+ def test_a_row_volume_leaves_the_bend_where_it_is(self) -> None:
+ instruction = PulseInstruction(
+ on=True,
+ pitch=REFERENCE,
+ volume=MAX_VOLUME,
+ duty_cycle=0,
+ detune=BEND,
+ )
+
+ sounded = apply_modifiers(instruction, 0, MAX_VOLUME // 2)
+
+ assert sounded.detune == BEND
diff --git a/tests/unit/sampletones_core/performance/test_instrument_walk.py b/tests/unit/sampletones_core/performance/test_instrument_walk.py
new file mode 100644
index 000000000..d1f5c0e92
--- /dev/null
+++ b/tests/unit/sampletones_core/performance/test_instrument_walk.py
@@ -0,0 +1,136 @@
+from dataclasses import dataclass
+from typing import Tuple
+
+import pytest
+
+from sampletones_core.constants.enums import ChannelName
+from sampletones_core.exporters import CHANNEL_TO_EXPORTER_MAP
+from sampletones_core.features.envelope import Envelope
+from sampletones_core.instructions import PulseInstruction
+from sampletones_core.performance import song_instructions
+from sampletones_core.project.voices.envelopes import InstrumentEnvelopes
+from sampletones_core.project.voices.instrument import Instrument
+from tests.suite.base import BaseTestSuite
+from tests.suite.case import BaseRegularTestCase
+from tests.suite.performance import place_instrument, project_with_instrument
+
+ROWS_PER_PATTERN: int = 4
+VOLUME: Tuple[int, ...] = (15, 10)
+ARPEGGIO: Tuple[int, ...] = (0, 5)
+
+
+def _instrument(loop: bool = False) -> Instrument:
+ point = 0 if loop else None
+ return Instrument(
+ name="lead",
+ envelopes=InstrumentEnvelopes(
+ volume=Envelope(items=VOLUME, loop_point=point),
+ arpeggio=Envelope(items=ARPEGGIO, loop_point=point),
+ duty_cycle=Envelope(items=(1,), loop_point=point),
+ ),
+ )
+
+
+def _resting(channel_name: ChannelName) -> object:
+ return CHANNEL_TO_EXPORTER_MAP[channel_name].get_instruction_type().null_instruction()
+
+
+class TestAnInstrumentSoundsOnEveryChannel(BaseTestSuite):
+ """A hand-written voice is placed on any channel, and the walk sounds the frames it makes there."""
+
+ @dataclass(frozen=True, kw_only=True)
+ class TestCase(BaseRegularTestCase):
+ channel_name: ChannelName
+
+ test_cases = (
+ TestCase(label=ChannelName.PULSE1.value, channel_name=ChannelName.PULSE1),
+ TestCase(label=ChannelName.PULSE2.value, channel_name=ChannelName.PULSE2),
+ TestCase(label=ChannelName.TRIANGLE.value, channel_name=ChannelName.TRIANGLE),
+ TestCase(label=ChannelName.NOISE.value, channel_name=ChannelName.NOISE),
+ )
+
+ @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label)
+ def test_the_walk_sounds_the_instrument_where_it_was_placed(self, test_case: TestCase) -> None:
+ instrument = _instrument()
+ project = project_with_instrument(instrument, rows_per_pattern=ROWS_PER_PATTERN)
+ place_instrument(
+ project,
+ channel_name=test_case.channel_name,
+ row_index=0,
+ sample=instrument,
+ )
+
+ streams = song_instructions(project)
+
+ assert streams[test_case.channel_name][: len(VOLUME)] == instrument.instructions(test_case.channel_name)
+
+ @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label)
+ def test_the_channels_it_was_not_placed_on_rest(self, test_case: TestCase) -> None:
+ instrument = _instrument()
+ project = project_with_instrument(instrument, rows_per_pattern=ROWS_PER_PATTERN)
+ place_instrument(
+ project,
+ channel_name=test_case.channel_name,
+ row_index=0,
+ sample=instrument,
+ )
+
+ streams = song_instructions(project)
+
+ for channel_name in ChannelName.items():
+ if channel_name is test_case.channel_name:
+ continue
+
+ assert set(streams[channel_name]) == {_resting(channel_name)}
+
+
+class TestAnInstrumentInASong:
+ def test_an_instrument_holds_its_final_values_past_its_envelopes(self) -> None:
+ """Every dimension halts on the value it wrote, which the note goes on sounding."""
+ instrument = _instrument()
+ project = project_with_instrument(instrument, rows_per_pattern=ROWS_PER_PATTERN)
+ place_instrument(project, channel_name=ChannelName.PULSE1, row_index=0, sample=instrument)
+
+ stream = song_instructions(project)[ChannelName.PULSE1]
+
+ held = stream[len(VOLUME)]
+ assert held is not None and held.volume == VOLUME[-1]
+ assert stream[len(VOLUME) :] == [held] * (len(stream) - len(VOLUME))
+
+ def test_a_volume_envelope_ending_at_silence_releases_the_note(self) -> None:
+ """A trailing zero is what stops a note, the way a tracker's own sequences end one."""
+ instrument = Instrument(
+ name="lead",
+ envelopes=InstrumentEnvelopes(volume=Envelope(items=(15, 10, 0))),
+ )
+ project = project_with_instrument(instrument, rows_per_pattern=ROWS_PER_PATTERN)
+ place_instrument(project, channel_name=ChannelName.PULSE1, row_index=0, sample=instrument)
+
+ stream = song_instructions(project)[ChannelName.PULSE1]
+
+ assert all(instruction is None or not instruction.on for instruction in stream[2:])
+
+ def test_a_looping_instrument_keeps_sounding(self) -> None:
+ instrument = _instrument(loop=True)
+ project = project_with_instrument(instrument, rows_per_pattern=ROWS_PER_PATTERN)
+ place_instrument(project, channel_name=ChannelName.PULSE1, row_index=0, sample=instrument)
+
+ stream = song_instructions(project)[ChannelName.PULSE1]
+
+ assert _resting(ChannelName.PULSE1) not in stream
+
+ def test_a_rows_transpose_bends_the_instrument_off_its_root(self) -> None:
+ instrument = _instrument()
+ project = project_with_instrument(instrument, rows_per_pattern=ROWS_PER_PATTERN)
+ place_instrument(
+ project,
+ channel_name=ChannelName.PULSE1,
+ row_index=0,
+ sample=instrument,
+ transpose=7,
+ )
+
+ first = song_instructions(project)[ChannelName.PULSE1][0]
+
+ assert isinstance(first, PulseInstruction)
+ assert first.pitch == instrument.initial_pitch + ARPEGGIO[0] + 7
diff --git a/tests/unit/sampletones_application/logic/sequencer/playback/test_apply_modifiers.py b/tests/unit/sampletones_core/performance/test_modifiers.py
similarity index 98%
rename from tests/unit/sampletones_application/logic/sequencer/playback/test_apply_modifiers.py
rename to tests/unit/sampletones_core/performance/test_modifiers.py
index 2b4dcaa08..80f2b5cac 100644
--- a/tests/unit/sampletones_application/logic/sequencer/playback/test_apply_modifiers.py
+++ b/tests/unit/sampletones_core/performance/test_modifiers.py
@@ -3,15 +3,13 @@
import pytest
-from sampletones_application.logic.sequencer.playback.synthesizer import (
- apply_modifiers,
-)
from sampletones_core.constants.general import MAX_PITCH, MAX_VOLUME, MIN_PITCH
from sampletones_core.instructions import (
NoiseInstruction,
PulseInstruction,
TriangleInstruction,
)
+from sampletones_core.performance import apply_modifiers
from tests.suite.case import BaseRegularTestCase, BaseTestCase
diff --git a/tests/unit/sampletones_core/performance/test_rows.py b/tests/unit/sampletones_core/performance/test_rows.py
new file mode 100644
index 000000000..839ad9e24
--- /dev/null
+++ b/tests/unit/sampletones_core/performance/test_rows.py
@@ -0,0 +1,177 @@
+from dataclasses import dataclass
+from typing import Optional, Tuple
+
+import pytest
+
+from sampletones_core.constants.enums import ChannelName
+from sampletones_core.constants.general import MAX_VOLUME
+from sampletones_core.performance import ChannelPerformance, apply_row, resolve_row
+from sampletones_core.project.patterns.row import Row
+from sampletones_core.project.song import Song
+from sampletones_core.project.song_position import SongPosition
+from sampletones_core.project.voices.note_off import NoteOff
+from sampletones_core.project.voices.note_on import NoteOn
+from tests.suite.base import BaseTestSuite
+from tests.suite.case import BaseRegularTestCase
+
+ROWS_PER_PATTERN: int = 4
+SOUNDING_ROW: int = 2
+SAMPLE_ID: str = "sample"
+ANOTHER_SAMPLE_ID: str = "another"
+
+
+def _song() -> Song:
+ """A one-frame song whose pulse pattern names a sample on ``SOUNDING_ROW``."""
+ song = Song.empty(ROWS_PER_PATTERN)
+ pattern = song.channels[ChannelName.PULSE1].patterns[0]
+ pattern.rows[SOUNDING_ROW] = Row(
+ command=NoteOn(voice_id=SAMPLE_ID),
+ )
+ return song
+
+
+class TestResolveRow(BaseTestSuite):
+ """Which row a channel reaches, over every way an order position answers with none."""
+
+ @dataclass(frozen=True, kw_only=True)
+ class TestCase(BaseRegularTestCase):
+ expected: bool
+ position: SongPosition
+ order_entry: Optional[int]
+
+ test_cases: Tuple["TestResolveRow.TestCase", ...] = (
+ TestCase(
+ label="the row the pattern states",
+ position=SongPosition(order_position=0, row_index=SOUNDING_ROW),
+ order_entry=0,
+ expected=True,
+ ),
+ TestCase(
+ label="a position past the order's end",
+ position=SongPosition(order_position=1, row_index=SOUNDING_ROW),
+ order_entry=0,
+ expected=False,
+ ),
+ TestCase(
+ label="a silent slot",
+ position=SongPosition(order_position=0, row_index=SOUNDING_ROW),
+ order_entry=None,
+ expected=False,
+ ),
+ TestCase(
+ label="a slot naming a pattern the pool has none of",
+ position=SongPosition(order_position=0, row_index=SOUNDING_ROW),
+ order_entry=7,
+ expected=False,
+ ),
+ TestCase(
+ label="a row past the pattern's length",
+ position=SongPosition(order_position=0, row_index=ROWS_PER_PATTERN),
+ order_entry=0,
+ expected=False,
+ ),
+ )
+
+ @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label)
+ def test_the_row_a_position_answers_with(self, test_case: TestCase) -> None:
+ song = _song()
+ song.set_order_entry(0, ChannelName.PULSE1, test_case.order_entry)
+
+ row = resolve_row(song, test_case.position, ChannelName.PULSE1)
+
+ assert (row is not None) is test_case.expected
+
+ def test_an_empty_row_is_answered_with_rather_than_skipped(self) -> None:
+ """A row stating nothing is still a row, which is what keeps a channel's state its own."""
+ song = _song()
+
+ row = resolve_row(song, SongPosition(order_position=0, row_index=0), ChannelName.PULSE1)
+
+ assert row == Row()
+
+
+class TestApplyRow(BaseTestSuite):
+ """What a row leaves the channel carrying, and whether the note starts over."""
+
+ @dataclass(frozen=True, kw_only=True)
+ class TestCase(BaseRegularTestCase):
+ expected: bool
+ row: Row
+ voice_id: Optional[str]
+ transpose: int
+ volume: int
+
+ test_cases: Tuple["TestApplyRow.TestCase", ...] = (
+ TestCase(
+ label="a note column with no modifiers takes the defaults",
+ row=Row(command=NoteOn(voice_id=SAMPLE_ID)),
+ voice_id=SAMPLE_ID,
+ transpose=0,
+ volume=MAX_VOLUME,
+ expected=True,
+ ),
+ TestCase(
+ label="a note column takes the modifiers the row states",
+ row=Row(
+ command=NoteOn(voice_id=SAMPLE_ID),
+ transpose=5,
+ volume=8,
+ ),
+ voice_id=SAMPLE_ID,
+ transpose=5,
+ volume=8,
+ expected=True,
+ ),
+ TestCase(
+ label="a note off silences the channel",
+ row=Row(command=NoteOff()),
+ voice_id=None,
+ transpose=3,
+ volume=8,
+ expected=True,
+ ),
+ TestCase(
+ label="an empty row leaves everything as it stands",
+ row=Row(),
+ voice_id=ANOTHER_SAMPLE_ID,
+ transpose=3,
+ volume=8,
+ expected=False,
+ ),
+ TestCase(
+ label="a modifier row bends the note already sounding",
+ row=Row(transpose=-2, volume=4),
+ voice_id=ANOTHER_SAMPLE_ID,
+ transpose=-2,
+ volume=4,
+ expected=False,
+ ),
+ )
+
+ @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label)
+ def test_what_the_channel_carries_after_the_row(self, test_case: TestCase) -> None:
+ performance = ChannelPerformance(
+ voice_id=ANOTHER_SAMPLE_ID,
+ tick_index=6,
+ transpose=3,
+ volume=8,
+ )
+
+ retriggered = apply_row(performance, test_case.row)
+
+ assert retriggered is test_case.expected
+ assert performance.voice_id == test_case.voice_id
+ assert performance.transpose == test_case.transpose
+ assert performance.volume == test_case.volume
+
+ @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label)
+ def test_the_tick_index_returns_to_the_start_exactly_where_the_note_does(
+ self,
+ test_case: TestCase,
+ ) -> None:
+ """A row that starts the note over is a row that starts its envelopes over."""
+ performance = ChannelPerformance(voice_id=ANOTHER_SAMPLE_ID, tick_index=6)
+
+ retriggered = apply_row(performance, test_case.row)
+
+ assert (performance.tick_index == 0) is retriggered
diff --git a/tests/unit/sampletones_core/performance/test_song.py b/tests/unit/sampletones_core/performance/test_song.py
new file mode 100644
index 000000000..2be4f2a4b
--- /dev/null
+++ b/tests/unit/sampletones_core/performance/test_song.py
@@ -0,0 +1,119 @@
+from typing import Final, List
+
+import pytest
+
+from sampletones_core.constants.enums import ChannelName
+from sampletones_core.exporters.maps import CHANNEL_TO_EXPORTER_MAP
+from sampletones_core.performance import WalkProgress, song_instructions
+from sampletones_core.project.project import Project
+from sampletones_core.project.settings import ProjectSettings
+from sampletones_core.timing import SongTiming
+from sampletones_shared.exceptions import OperationCanceled
+from tests.suite.performance import (
+ make_pulse_reconstruction,
+ place_instrument,
+ project_with_sample,
+)
+
+ROWS_PER_PATTERN: Final[int] = 4
+ENVELOPE_TICKS: Final[int] = 2
+SETTINGS: Final[ProjectSettings] = ProjectSettings(tempo=150, speed=6, nes_frequency=60)
+
+
+def _project() -> Project:
+ """A one-frame project sounding a two-tick pulse envelope from the first row."""
+ project, sample = project_with_sample(
+ make_pulse_reconstruction(count=ENVELOPE_TICKS),
+ rows_per_pattern=ROWS_PER_PATTERN,
+ settings=SETTINGS,
+ )
+ place_instrument(
+ project,
+ channel_name=ChannelName.PULSE1,
+ row_index=0,
+ sample=sample,
+ )
+ return project
+
+
+def _resting(channel_name: ChannelName) -> object:
+ return CHANNEL_TO_EXPORTER_MAP[channel_name].get_instruction_type().null_instruction()
+
+
+class TestSongInstructions:
+ """The four streams a song plays out as, one instruction per channel per engine tick."""
+
+ def test_the_song_lasts_the_ticks_its_groove_gives_every_row_it_plays(self) -> None:
+ project = _project()
+ groove = SongTiming.from_project(project).groove()
+
+ streams = song_instructions(project)
+
+ expected_ticks = project.song.order_length() * groove.total_ticks
+ assert len(streams[ChannelName.PULSE1]) == expected_ticks
+
+ def test_every_channel_answers_for_every_tick(self) -> None:
+ """One length across the four streams is what makes a tick index one moment of the song."""
+ streams = song_instructions(_project())
+
+ assert len({len(stream) for stream in streams.values()}) == 1
+
+ def test_a_channel_no_row_names_rests_throughout(self) -> None:
+ streams = song_instructions(_project())
+
+ assert streams[ChannelName.TRIANGLE] == [_resting(ChannelName.TRIANGLE)] * len(streams[ChannelName.TRIANGLE])
+
+ def test_a_sounding_channel_falls_silent_once_its_envelope_plays_out(self) -> None:
+ """A one-shot sounds for the ticks it carries and rests for the rest of the row."""
+ stream = song_instructions(_project())[ChannelName.PULSE1]
+ resting = _resting(ChannelName.PULSE1)
+
+ assert stream[:ENVELOPE_TICKS] != [resting] * ENVELOPE_TICKS
+ assert stream[ENVELOPE_TICKS:] == [resting] * (len(stream) - ENVELOPE_TICKS)
+
+ def test_a_pattern_the_order_plays_twice_sounds_alike_both_times(self) -> None:
+ """The order is where reuse lives, so the ticks a repeated frame produces are the same."""
+ project = _project()
+ project.song.append_frame()
+ project.song.set_order_entry(1, ChannelName.PULSE1, 0)
+ groove = SongTiming.from_project(project).groove()
+
+ stream = song_instructions(project)[ChannelName.PULSE1]
+
+ frame_ticks = groove.total_ticks
+ assert stream[:frame_ticks] == stream[frame_ticks : 2 * frame_ticks]
+
+
+class TestWhatAWalkSaysAboutItself:
+ """A song of minutes is played out row by row, so the walk says how far along it is."""
+
+ def test_the_walk_reports_each_row_it_sounds(self) -> None:
+ project = _project()
+ heard: List[WalkProgress] = []
+ song_instructions(project, lambda progress: heard.append(progress) is None)
+ assert len(heard) == project.song.order_length() * project.song.rows_per_pattern
+
+ def test_the_walk_states_the_ticks_the_order_lasts(self) -> None:
+ """The groove states the length before a row is played, so every report names the same."""
+ project = _project()
+ heard: List[WalkProgress] = []
+ song_instructions(project, lambda progress: heard.append(progress) is None)
+ expected = project.song.order_length() * SongTiming.from_project(project).groove().total_ticks
+ assert [progress.total for progress in heard] == [expected] * len(heard)
+
+ def test_the_walk_reaches_the_ticks_it_set_out_to_sound(self) -> None:
+ project = _project()
+ heard: List[WalkProgress] = []
+ song_instructions(project, lambda progress: heard.append(progress) is None)
+ assert heard[-1].ticks == heard[-1].total
+
+ def test_the_walk_counts_up_as_it_goes(self) -> None:
+ project = _project()
+ heard: List[WalkProgress] = []
+ song_instructions(project, lambda progress: heard.append(progress) is None)
+ counted = [progress.ticks for progress in heard]
+ assert counted == sorted(counted)
+
+ def test_a_withdrawn_walk_stops_where_it_was_told(self) -> None:
+ with pytest.raises(OperationCanceled):
+ song_instructions(_project(), lambda progress: False)
diff --git a/tests/unit/sampletones_core/performance/test_ticks.py b/tests/unit/sampletones_core/performance/test_ticks.py
new file mode 100644
index 000000000..6dd9756b5
--- /dev/null
+++ b/tests/unit/sampletones_core/performance/test_ticks.py
@@ -0,0 +1,138 @@
+from dataclasses import dataclass
+from typing import Optional, Tuple
+
+import pytest
+
+from sampletones_core.constants.enums import ChannelName
+from sampletones_core.constants.general import MAX_VOLUME
+from sampletones_core.features.envelope import Envelope
+from sampletones_core.instructions import InstructionUnion, PulseInstruction
+from sampletones_core.performance import ChannelPerformance, VoiceReading, sound_tick
+from sampletones_core.project.voices.envelopes import InstrumentEnvelopes
+from sampletones_core.project.voices.instrument import Instrument
+from sampletones_core.project.voices.sample import Sample
+from sampletones_core.project.voices.voice import VoiceUnion
+from tests.suite.base import BaseTestSuite
+from tests.suite.case import BaseRegularTestCase
+from tests.suite.performance import make_pulse_reconstruction
+
+ENVELOPE_TICKS: int = 3
+SOUNDING_PITCH: int = 60
+
+
+def _reading(*, sustaining: bool = False) -> VoiceReading:
+ """A pulse voice over a three-tick envelope, read the way a channel reads it.
+
+ A recording sounds the frames its conversion found; an instrument writing the same three
+ ticks holds its final values past them, so the two kinds part company where the written
+ run ends.
+ """
+ voice: VoiceUnion = _instrument() if sustaining else _sample()
+ reading = VoiceReading.read(voice, ChannelName.PULSE1)
+ assert reading is not None
+ return reading
+
+
+def _sample() -> Sample:
+ return Sample(
+ name="lead",
+ reconstruction=make_pulse_reconstruction(pitch=SOUNDING_PITCH, count=ENVELOPE_TICKS),
+ )
+
+
+def _instrument() -> Instrument:
+ return Instrument(
+ name="lead",
+ envelopes=InstrumentEnvelopes(volume=Envelope[int](items=(MAX_VOLUME,) * ENVELOPE_TICKS)),
+ initial_pitch=SOUNDING_PITCH,
+ )
+
+
+class TestSoundTick(BaseTestSuite):
+ """Which of a voice's instructions a channel reaches, and where it runs out."""
+
+ @dataclass(frozen=True, kw_only=True)
+ class TestCase(BaseRegularTestCase):
+ expected: bool
+ tick_index: int
+ sustaining: bool
+
+ test_cases: Tuple["TestSoundTick.TestCase", ...] = (
+ TestCase(label="a recording within its frames", tick_index=0, sustaining=False, expected=True),
+ TestCase(
+ label="a recording on its final frame",
+ tick_index=ENVELOPE_TICKS - 1,
+ sustaining=False,
+ expected=True,
+ ),
+ TestCase(
+ label="a recording past its frames",
+ tick_index=ENVELOPE_TICKS,
+ sustaining=False,
+ expected=False,
+ ),
+ TestCase(
+ label="an instrument past its envelopes",
+ tick_index=ENVELOPE_TICKS,
+ sustaining=True,
+ expected=True,
+ ),
+ TestCase(
+ label="an instrument several passes on",
+ tick_index=ENVELOPE_TICKS * 4 + 1,
+ sustaining=True,
+ expected=True,
+ ),
+ )
+
+ @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label)
+ def test_whether_the_channel_still_sounds(self, test_case: TestCase) -> None:
+ reading = _reading(sustaining=test_case.sustaining)
+ performance = ChannelPerformance(tick_index=test_case.tick_index)
+
+ instruction = sound_tick(performance, reading)
+
+ assert (instruction is not None) is test_case.expected
+
+ @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label)
+ def test_the_channel_moves_on_whether_or_not_it_sounds(self, test_case: TestCase) -> None:
+ """A voice that has played out keeps counting, so the tick index states the song's time."""
+ reading = _reading(sustaining=test_case.sustaining)
+ performance = ChannelPerformance(tick_index=test_case.tick_index)
+
+ sound_tick(performance, reading)
+
+ assert performance.tick_index == test_case.tick_index + 1
+
+ def test_a_recording_rests_once_its_frames_are_played(self) -> None:
+ """A recording is a fixed run, so a note holding past it leaves the channel silent."""
+ reading = _reading()
+ performance = ChannelPerformance()
+
+ sounded = [sound_tick(performance, reading) for _ in range(ENVELOPE_TICKS * 2)]
+
+ assert sounded[ENVELOPE_TICKS:] == [None] * ENVELOPE_TICKS
+
+ def test_an_instrument_goes_on_holding_its_final_frame(self) -> None:
+ """An instrument's dimensions hold their last item, so the frame it ended on sounds on."""
+ reading = _reading(sustaining=True)
+ performance = ChannelPerformance()
+
+ sounded = [sound_tick(performance, reading) for _ in range(ENVELOPE_TICKS * 2)]
+
+ assert sounded[ENVELOPE_TICKS:] == [sounded[ENVELOPE_TICKS - 1]] * ENVELOPE_TICKS
+
+ def test_the_row_bends_the_instruction_the_voice_holds(self) -> None:
+ """The transpose and volume a row reached are applied to what the channel sounds."""
+ reading = _reading()
+ transpose = 7
+ volume = MAX_VOLUME // 3
+ performance = ChannelPerformance(transpose=transpose, volume=volume)
+
+ instruction = sound_tick(performance, reading)
+
+ held: Optional[InstructionUnion] = reading.instructions[0]
+ assert isinstance(instruction, PulseInstruction)
+ assert isinstance(held, PulseInstruction)
+ assert instruction.pitch == held.pitch + transpose
+ assert instruction.volume == round(held.volume * volume / MAX_VOLUME)
diff --git a/tests/unit/sampletones_application/logic/sequencer/playback/test_voice.py b/tests/unit/sampletones_core/performance/test_voice.py
similarity index 80%
rename from tests/unit/sampletones_application/logic/sequencer/playback/test_voice.py
rename to tests/unit/sampletones_core/performance/test_voice.py
index e7b1778eb..059852aef 100644
--- a/tests/unit/sampletones_application/logic/sequencer/playback/test_voice.py
+++ b/tests/unit/sampletones_core/performance/test_voice.py
@@ -5,9 +5,8 @@
import numpy as np
import pytest
-from sampletones_application.logic.sequencer.playback.synthesizer import SampleVoice
from sampletones_core.configs import Config
-from sampletones_core.constants.enums import FeatureKey, GeneratorName
+from sampletones_core.constants.enums import ChannelName, FeatureKey
from sampletones_core.constants.general import MAX_VOLUME
from sampletones_core.features import CHANNEL_FEATURE_DEFAULTS
from sampletones_core.instructions import (
@@ -16,9 +15,12 @@
PulseInstruction,
TriangleInstruction,
)
+from sampletones_core.performance import VoiceReading
+from sampletones_core.project.voices.sample import Sample
from sampletones_core.reconstructions import Reconstruction
from tests.suite.base import BaseTestSuite
from tests.suite.case import BaseRegularTestCase
+from tests.suite.stems import single_entry_stems_data
AUDIO_LENGTH: Final[int] = 64
REFERENCE_PITCH: Final[int] = 60
@@ -32,35 +34,43 @@
def _reconstruction(
- generator_name: GeneratorName,
+ channel_name: ChannelName,
instructions: Sequence[InstructionUnion],
held_features: Iterable[FeatureKey],
) -> Reconstruction:
"""A one-channel reconstruction whose instrument leaves ``held_features`` to the channel."""
+ instructions_list = list(instructions)
reconstruction = Reconstruction.create(
approximation=np.zeros(AUDIO_LENGTH, dtype=np.float32),
- approximations={generator_name: np.zeros(AUDIO_LENGTH, dtype=np.float32)},
- instructions={generator_name: list(instructions)},
+ approximations={channel_name: np.zeros(AUDIO_LENGTH, dtype=np.float32)},
+ instructions={channel_name: instructions_list},
config=Config(),
coefficient=1.0,
- audio_filepath=Path("/dev/null"),
+ audio_filepath=(Path("/dev/null"),),
+ stems_data=single_entry_stems_data(
+ list(Config().generation.channels),
+ {channel_name: instructions_list},
+ ),
)
- reconstruction.update_generator_data(
- generator_name,
+ reconstruction.update_channel_data(
+ channel_name,
list(instructions),
np.ones(AUDIO_LENGTH, dtype=np.float32),
- reconstruction.initial_pitches[generator_name],
+ reconstruction.initial_pitches[channel_name],
held_features,
)
return reconstruction
def _voice(
- generator_name: GeneratorName,
+ channel_name: ChannelName,
instructions: Sequence[InstructionUnion],
held_features: Iterable[FeatureKey],
-) -> SampleVoice:
- return SampleVoice.read(_reconstruction(generator_name, instructions, held_features), generator_name)
+) -> VoiceReading:
+ voice = Sample(name="lead", reconstruction=_reconstruction(channel_name, instructions, held_features))
+ reading = VoiceReading.read(voice, channel_name)
+ assert reading is not None
+ return reading
def _channel_values() -> Dict[FeatureKey, int]:
@@ -72,13 +82,13 @@ class TestAFrameSoundsAsTheInstrumentWroteIt(BaseTestSuite):
@dataclass(frozen=True, kw_only=True)
class TestCase(BaseRegularTestCase):
- generator_name: GeneratorName
+ channel_name: ChannelName
instructions: List[InstructionUnion]
test_cases = (
TestCase(
label="pulse",
- generator_name=GeneratorName.PULSE1,
+ channel_name=ChannelName.PULSE1,
instructions=[
PulseInstruction(
on=True,
@@ -90,12 +100,12 @@ class TestCase(BaseRegularTestCase):
),
TestCase(
label="triangle",
- generator_name=GeneratorName.TRIANGLE,
+ channel_name=ChannelName.TRIANGLE,
instructions=[TriangleInstruction(on=True, pitch=REFERENCE_PITCH)],
),
TestCase(
label="noise",
- generator_name=GeneratorName.NOISE,
+ channel_name=ChannelName.NOISE,
instructions=[
NoiseInstruction(
on=True,
@@ -113,7 +123,7 @@ class TestCase(BaseRegularTestCase):
ids=lambda test_case: test_case.label,
)
def test_the_frame_plays_as_it_stands(self, test_case: TestCase) -> None:
- voice = _voice(test_case.generator_name, test_case.instructions, ())
+ voice = _voice(test_case.channel_name, test_case.instructions, ())
assert voice.sound(test_case.instructions[0], _channel_values()) == test_case.instructions[0]
@@ -123,7 +133,7 @@ def test_the_frame_plays_as_it_stands(self, test_case: TestCase) -> None:
ids=lambda test_case: test_case.label,
)
def test_the_channel_takes_up_what_the_instrument_writes(self, test_case: TestCase) -> None:
- voice = _voice(test_case.generator_name, test_case.instructions, ())
+ voice = _voice(test_case.channel_name, test_case.instructions, ())
values = _channel_values()
voice.sound(test_case.instructions[0], values)
@@ -150,7 +160,7 @@ class TestAHeldDimensionSoundsAtTheChannelsValue(BaseTestSuite):
@dataclass(frozen=True, kw_only=True)
class TestCase(BaseRegularTestCase):
- generator_name: GeneratorName
+ channel_name: ChannelName
instruction: InstructionUnion
held_feature: FeatureKey
channel_value: int
@@ -159,7 +169,7 @@ class TestCase(BaseRegularTestCase):
test_cases = (
TestCase(
label="pulse volume",
- generator_name=GeneratorName.PULSE1,
+ channel_name=ChannelName.PULSE1,
instruction=_INSTRUCTION,
held_feature=FeatureKey.VOLUME,
channel_value=CHANNEL_VOLUME,
@@ -172,7 +182,7 @@ class TestCase(BaseRegularTestCase):
),
TestCase(
label="pulse arpeggio",
- generator_name=GeneratorName.PULSE1,
+ channel_name=ChannelName.PULSE1,
instruction=_INSTRUCTION,
held_feature=FeatureKey.ARPEGGIO,
channel_value=CHANNEL_ARPEGGIO,
@@ -185,7 +195,7 @@ class TestCase(BaseRegularTestCase):
),
TestCase(
label="pulse duty cycle",
- generator_name=GeneratorName.PULSE1,
+ channel_name=ChannelName.PULSE1,
instruction=_INSTRUCTION,
held_feature=FeatureKey.DUTY_CYCLE,
channel_value=CHANNEL_DUTY_CYCLE,
@@ -198,7 +208,7 @@ class TestCase(BaseRegularTestCase):
),
TestCase(
label="triangle arpeggio",
- generator_name=GeneratorName.TRIANGLE,
+ channel_name=ChannelName.TRIANGLE,
instruction=TriangleInstruction(on=True, pitch=REFERENCE_PITCH),
held_feature=FeatureKey.ARPEGGIO,
channel_value=CHANNEL_ARPEGGIO,
@@ -206,7 +216,7 @@ class TestCase(BaseRegularTestCase):
),
TestCase(
label="noise period",
- generator_name=GeneratorName.NOISE,
+ channel_name=ChannelName.NOISE,
instruction=NoiseInstruction(
on=True,
period=REFERENCE_PERIOD,
@@ -224,7 +234,7 @@ class TestCase(BaseRegularTestCase):
),
TestCase(
label="noise mode",
- generator_name=GeneratorName.NOISE,
+ channel_name=ChannelName.NOISE,
instruction=NoiseInstruction(
on=True,
period=REFERENCE_PERIOD,
@@ -248,7 +258,7 @@ class TestCase(BaseRegularTestCase):
ids=lambda test_case: test_case.label,
)
def test_the_frame_sounds_the_channels_value_and_the_instruments_rest(self, test_case: TestCase) -> None:
- voice = _voice(test_case.generator_name, [test_case.instruction], (test_case.held_feature,))
+ voice = _voice(test_case.channel_name, [test_case.instruction], (test_case.held_feature,))
values = _channel_values()
values[test_case.held_feature] = test_case.channel_value
@@ -260,7 +270,7 @@ def test_the_frame_sounds_the_channels_value_and_the_instruments_rest(self, test
ids=lambda test_case: test_case.label,
)
def test_a_held_dimension_leaves_the_channels_value_where_it_stands(self, test_case: TestCase) -> None:
- voice = _voice(test_case.generator_name, [test_case.instruction], (test_case.held_feature,))
+ voice = _voice(test_case.channel_name, [test_case.instruction], (test_case.held_feature,))
values = _channel_values()
values[test_case.held_feature] = test_case.channel_value
@@ -270,8 +280,8 @@ def test_a_held_dimension_leaves_the_channels_value_where_it_stands(self, test_c
def test_a_level_one_instrument_wrote_is_what_the_next_one_holds(self) -> None:
"""The channel carries a value across samples, which is what makes an empty envelope mean this."""
- writes = _voice(GeneratorName.PULSE1, [self._INSTRUCTION], ())
- holds = _voice(GeneratorName.PULSE1, [self._INSTRUCTION], (FeatureKey.VOLUME,))
+ writes = _voice(ChannelName.PULSE1, [self._INSTRUCTION], ())
+ holds = _voice(ChannelName.PULSE1, [self._INSTRUCTION], (FeatureKey.VOLUME,))
values = _channel_values()
writes.sound(self._INSTRUCTION, values)
@@ -281,13 +291,13 @@ def test_a_level_one_instrument_wrote_is_what_the_next_one_holds(self) -> None:
def test_an_instrument_holding_its_level_sounds_a_silent_frame(self) -> None:
"""Silence is stated by a volume envelope, so an instrument leaving one out plays on."""
rest = PulseInstruction.null_instruction()
- voice = _voice(GeneratorName.PULSE1, [self._INSTRUCTION, rest], (FeatureKey.VOLUME,))
+ voice = _voice(ChannelName.PULSE1, [self._INSTRUCTION, rest], (FeatureKey.VOLUME,))
assert voice.sound(rest, _channel_values()).on is True
def test_a_silent_frame_takes_the_channel_to_silence_where_the_instrument_writes_its_level(self) -> None:
rest = PulseInstruction.null_instruction()
- voice = _voice(GeneratorName.PULSE1, [self._INSTRUCTION, rest], ())
+ voice = _voice(ChannelName.PULSE1, [self._INSTRUCTION, rest], ())
values = _channel_values()
assert voice.sound(rest, values).on is False
@@ -295,7 +305,7 @@ def test_a_silent_frame_takes_the_channel_to_silence_where_the_instrument_writes
def test_a_silent_frame_leaves_the_other_dimensions_where_the_channel_holds_them(self) -> None:
rest = PulseInstruction.null_instruction()
- voice = _voice(GeneratorName.PULSE1, [self._INSTRUCTION, rest], ())
+ voice = _voice(ChannelName.PULSE1, [self._INSTRUCTION, rest], ())
values = _channel_values()
values[FeatureKey.DUTY_CYCLE] = DUTY_CYCLE
diff --git a/tests/unit/sampletones_core/project/patterns/test_channel.py b/tests/unit/sampletones_core/project/patterns/test_channel.py
index bf55412ce..1ddd34486 100644
--- a/tests/unit/sampletones_core/project/patterns/test_channel.py
+++ b/tests/unit/sampletones_core/project/patterns/test_channel.py
@@ -1,13 +1,13 @@
-from sampletones_core.constants.enums import GeneratorName
-from sampletones_core.project.instruments.instrument import Instrument
+from sampletones_core.constants.enums import ChannelName
from sampletones_core.project.patterns.channel import Channel
from sampletones_core.project.patterns.row import Row
+from sampletones_core.project.voices.note_on import NoteOn
ROWS_PER_PATTERN = 16
def _channel() -> Channel:
- return Channel.empty(GeneratorName.PULSE1, ROWS_PER_PATTERN)
+ return Channel.empty(ChannelName.PULSE1, ROWS_PER_PATTERN)
class TestPatternPool:
@@ -21,7 +21,7 @@ def test_clone_pattern_copies_rows_with_new_identity(self) -> None:
channel = _channel()
source = channel.patterns[0]
source.rows[0] = Row(
- instrument=Instrument(sample_id="abc", generator_name=GeneratorName.PULSE1),
+ instrument=NoteOn(voice_id="abc"),
volume=10,
)
diff --git a/tests/unit/sampletones_core/project/patterns/test_pattern.py b/tests/unit/sampletones_core/project/patterns/test_pattern.py
index 6f2815f88..35230f9c6 100644
--- a/tests/unit/sampletones_core/project/patterns/test_pattern.py
+++ b/tests/unit/sampletones_core/project/patterns/test_pattern.py
@@ -1,9 +1,9 @@
import pytest
-from sampletones_core.constants.enums import GeneratorName
-from sampletones_core.project.instruments.instrument import Instrument
+from sampletones_core.constants.enums import ChannelName
from sampletones_core.project.patterns.pattern import Pattern
from sampletones_core.project.patterns.row import Row
+from sampletones_core.project.voices.note_on import NoteOn
_LENGTH = 4
@@ -13,7 +13,7 @@ def _empty_pattern() -> Pattern:
def _row_with_instrument() -> Row:
- return Row(command=Instrument(sample_id="x", generator_name=GeneratorName.PULSE1))
+ return Row(command=NoteOn(voice_id="x"))
class TestRowIsEmpty:
@@ -21,7 +21,7 @@ def test_default_row_is_empty(self) -> None:
assert Row().is_empty()
def test_row_with_instrument_is_not_empty(self) -> None:
- assert not Row(command=Instrument(sample_id="x", generator_name=GeneratorName.PULSE1)).is_empty()
+ assert not Row(command=NoteOn(voice_id="x")).is_empty()
def test_row_with_transpose_is_not_empty(self) -> None:
assert not Row(transpose=0).is_empty()
@@ -32,13 +32,13 @@ def test_row_with_volume_is_not_empty(self) -> None:
class TestRowReferencesSample:
def test_default_row_references_no_sample(self) -> None:
- assert not Row().references_sample("x")
+ assert not Row().references_voice("x")
def test_row_references_its_instrument_sample(self) -> None:
- assert _row_with_instrument().references_sample("x")
+ assert _row_with_instrument().references_voice("x")
def test_row_does_not_reference_a_different_sample(self) -> None:
- assert not _row_with_instrument().references_sample("y")
+ assert not _row_with_instrument().references_voice("y")
class TestPatternIsEmpty:
diff --git a/tests/unit/sampletones_core/project/test_container.py b/tests/unit/sampletones_core/project/test_container.py
index 22051afab..58907cfd1 100644
--- a/tests/unit/sampletones_core/project/test_container.py
+++ b/tests/unit/sampletones_core/project/test_container.py
@@ -1,17 +1,21 @@
import json
import zipfile
from pathlib import Path
+from typing import Any, Callable, Dict, Final, Tuple
from unittest.mock import patch
import pytest
-from sampletones_core.constants.enums import GeneratorName
+from sampletones_core.constants.enums import ChannelName
from sampletones_core.data import Metadata
+from sampletones_core.features.envelope import Envelope
from sampletones_core.project.container import ProjectContainer
-from sampletones_core.project.instruments.instrument import Instrument
-from sampletones_core.project.instruments.sample import Sample
from sampletones_core.project.patterns.row import Row
from sampletones_core.project.project import Project
+from sampletones_core.project.voices.envelopes import InstrumentEnvelopes
+from sampletones_core.project.voices.instrument import Instrument
+from sampletones_core.project.voices.note_on import NoteOn
+from sampletones_core.project.voices.sample import Sample
from sampletones_shared.application import SAMPLETONES_PROJECT_DATA_VERSION
from sampletones_shared.constants.project import (
PROJECT_DOCUMENT_NAME,
@@ -28,6 +32,11 @@
from tests.conftest import ReconstructionFactory
from tests.suite.errors import DIRECTORY_READ_ERRORS
+Document = Dict[str, Any]
+DocumentRewrite = Callable[[Document], Document]
+
+FORMAT_1_0_SAMPLE_FIELDS: Final[Tuple[str, ...]] = ("id", "name", "reconstruction_id")
+
def _rewrite_format_version(source: Path, target: Path, *, format_version: str) -> None:
with zipfile.ZipFile(source, "r") as archive:
@@ -42,6 +51,64 @@ def _rewrite_format_version(source: Path, target: Path, *, format_version: str)
archive.writestr(name, data)
+def _rewrite_document(source: Path, target: Path, rewrite: DocumentRewrite) -> None:
+ with zipfile.ZipFile(source, "r") as archive:
+ members = {name: archive.read(name) for name in archive.namelist()}
+
+ document = json.loads(members[PROJECT_DOCUMENT_NAME].decode("utf-8"))
+ members[PROJECT_DOCUMENT_NAME] = json.dumps(rewrite(document)).encode("utf-8")
+
+ with zipfile.ZipFile(target, "w") as archive:
+ for name, data in members.items():
+ archive.writestr(name, data)
+
+
+def _as_format_1_0(document: Document) -> Document:
+ """The document as project format 1.0 wrote it, the shape the upgrade chain reads.
+
+ Format 1.0 held the pool under ``samples``, each record naming an id, a name and the
+ reconstruction it references; named a channel pool's channel ``generator``; and wrote a note
+ command as a sample id beside the channel slice it named.
+ """
+ samples = [{field: voice[field] for field in FORMAT_1_0_SAMPLE_FIELDS} for voice in document["voices"]]
+ channels = {name: _pool_as_format_1_0(name, pool) for name, pool in document["song"]["channels"].items()}
+ downgraded = {key: value for key, value in document.items() if key != "voices"}
+ return {
+ **downgraded,
+ "format_version": "1.0",
+ "samples": samples,
+ "song": {**document["song"], "channels": channels},
+ }
+
+
+def _pool_as_format_1_0(channel_name: str, pool: Document) -> Document:
+ patterns = {index: _pattern_as_format_1_0(channel_name, pattern) for index, pattern in pool["patterns"].items()}
+ return {
+ "generator": pool["name"],
+ **{key: value for key, value in pool.items() if key != "name"},
+ "patterns": patterns,
+ }
+
+
+def _pattern_as_format_1_0(channel_name: str, pattern: Document) -> Document:
+ rows = [_row_as_format_1_0(channel_name, row) for row in pattern["rows"]]
+ return {**pattern, "rows": rows}
+
+
+def _row_as_format_1_0(channel_name: str, row: Document) -> Document:
+ command = row.get("command")
+ if not isinstance(command, dict) or "voice_id" not in command:
+ return row
+
+ return {
+ **row,
+ "command": {
+ "sample_id": command["voice_id"],
+ "generator_name": channel_name,
+ },
+ }
+
+
def _populated_project(
reconstruction_factory: ReconstructionFactory,
shared: bool = False,
@@ -52,26 +119,23 @@ def _populated_project(
first = Sample(name="lead", reconstruction=reconstruction_factory())
second_reconstruction = first.reconstruction if shared else reconstruction_factory()
second = Sample(name="bass", reconstruction=second_reconstruction)
- project.samples.extend([first, second])
+ project.voices.extend([first, second])
song = project.song
- channel = song[GeneratorName.PULSE1]
+ channel = song[ChannelName.PULSE1]
pattern = channel.patterns[0]
pattern.name = "intro"
pattern.rows[0] = Row(
transpose=0,
- command=Instrument(
- sample_id=first.id,
- generator_name=GeneratorName.PULSE1,
- ),
+ command=NoteOn(voice_id=first.id),
volume=15,
)
extra_index = channel.add_pattern(song.rows_per_pattern, name="verse")
song.append_frame()
- song.set_order_entry(1, GeneratorName.PULSE1, extra_index)
+ song.set_order_entry(1, ChannelName.PULSE1, extra_index)
song.append_frame()
- song.set_order_entry(2, GeneratorName.PULSE1, 0)
+ song.set_order_entry(2, ChannelName.PULSE1, 0)
return project
@@ -90,21 +154,21 @@ def test_full_round_trip(
assert loaded.metadata == project.metadata
assert loaded.info.title == project.info.title
assert loaded.settings.tempo == 128
- assert [sample.id for sample in loaded.samples] == [sample.id for sample in project.samples]
- assert [sample.name for sample in loaded.samples] == ["lead", "bass"]
+ assert [sample.id for sample in loaded.voices] == [sample.id for sample in project.voices]
+ assert [sample.name for sample in loaded.voices] == ["lead", "bass"]
loaded_song = loaded.song
assert loaded_song.order == project.song.order
- pulse1_index_at_0 = loaded_song.order[0].get(GeneratorName.PULSE1)
+ pulse1_index_at_0 = loaded_song.order[0].get(ChannelName.PULSE1)
first_pattern = loaded_song.pattern(
- GeneratorName.PULSE1,
+ ChannelName.PULSE1,
pulse1_index_at_0,
)
assert first_pattern.name == "intro"
row = first_pattern.rows[0]
assert row.transpose == 0
assert row.command is not None
- assert row.command.sample_id == loaded.samples[0].id
+ assert row.command.voice_id == loaded.voices[0].id
def test_references_resolve_after_load(
self,
@@ -117,14 +181,89 @@ def test_references_resolve_after_load(
loaded = ProjectContainer.load(path)
loaded_song = loaded.song
- channel = loaded_song[GeneratorName.PULSE1]
- index_at_0 = loaded_song.order[0].get(GeneratorName.PULSE1)
+ channel = loaded_song[ChannelName.PULSE1]
+ index_at_0 = loaded_song.order[0].get(ChannelName.PULSE1)
row = channel.pattern(index_at_0).rows[0]
- assert loaded.sample(row.command.sample_id) is loaded.samples[0]
- index_at_2 = loaded_song.order[2].get(GeneratorName.PULSE1)
+ assert loaded.voice(row.command.voice_id) is loaded.voices[0]
+ index_at_2 = loaded_song.order[2].get(ChannelName.PULSE1)
assert channel.pattern(index_at_0) is channel.pattern(index_at_2)
+class TestInstrumentsRoundTrip:
+ """An instrument carries no payload beside itself, so a project holds it whole in its document."""
+
+ def test_an_instrument_survives_a_round_trip(self, tmp_path: Path) -> None:
+ project = Project.create(title="Demo")
+ instrument = Instrument(
+ name="lead",
+ envelopes=InstrumentEnvelopes(
+ volume=Envelope(items=(15, 12), loop_point=0),
+ arpeggio=Envelope(items=(0, 7)),
+ duty_cycle=Envelope(items=(2,)),
+ ),
+ initial_pitch=55,
+ initial_period=3,
+ )
+ project.voices.append(instrument)
+ path = tmp_path / "demo.stp"
+
+ ProjectContainer.save(project, path)
+ loaded = ProjectContainer.load(path)
+
+ assert loaded.voices[0] == instrument
+ restored = loaded.voice(instrument.id)
+ assert isinstance(restored, Instrument)
+ assert restored.envelopes == instrument.envelopes
+ assert restored.initial_pitch == instrument.initial_pitch
+ assert restored.initial_period == instrument.initial_period
+ assert restored.envelopes.volume.loop_point == instrument.envelopes.volume.loop_point
+
+ def test_an_instrument_leaves_no_reconstruction_in_the_archive(self, tmp_path: Path) -> None:
+ project = Project.create(title="Demo")
+ project.voices.append(Instrument(name="lead"))
+ path = tmp_path / "demo.stp"
+
+ ProjectContainer.save(project, path)
+
+ with zipfile.ZipFile(path) as archive:
+ assert [name for name in archive.namelist() if name.startswith(RECONSTRUCTIONS_DIRECTORY)] == []
+
+ def test_both_kinds_share_one_pool_in_their_written_order(
+ self,
+ tmp_path: Path,
+ reconstruction_factory: ReconstructionFactory,
+ ) -> None:
+ project = Project.create(title="Demo")
+ sample = Sample(name="bass", reconstruction=reconstruction_factory())
+ instrument = Instrument(name="lead")
+ project.voices.extend([sample, instrument])
+ path = tmp_path / "demo.stp"
+
+ ProjectContainer.save(project, path)
+ loaded = ProjectContainer.load(path)
+
+ assert [voice.id for voice in loaded.voices] == [sample.id, instrument.id]
+ assert isinstance(loaded.voices[0], Sample)
+ assert isinstance(loaded.voices[1], Instrument)
+
+ def test_a_row_naming_an_instrument_still_names_it_after_a_round_trip(
+ self,
+ tmp_path: Path,
+ ) -> None:
+ project = Project.create(title="Demo")
+ instrument = Instrument(name="lead", envelopes=InstrumentEnvelopes(volume=Envelope(items=(15,))))
+ project.voices.append(instrument)
+ project.song[ChannelName.PULSE1].patterns[0].rows[0] = Row(command=NoteOn(voice_id=instrument.id))
+ path = tmp_path / "demo.stp"
+
+ ProjectContainer.save(project, path)
+ loaded = ProjectContainer.load(path)
+
+ row = loaded.song[ChannelName.PULSE1].patterns[0].rows[0]
+ assert row.command is not None
+ assert loaded.voice(row.command.voice_id) is loaded.voices[0]
+
+
class TestArchiveLayout:
def test_unique_reconstructions_are_deduplicated(
self,
@@ -171,7 +310,7 @@ def test_document_is_plain_json(
document = json.loads(archive.read(PROJECT_DOCUMENT_NAME).decode("utf-8"))
assert document["format_version"] == SAMPLETONES_PROJECT_DATA_VERSION
- assert set(document["song"]["channels"]) == {generator.value for generator in GeneratorName.items()}
+ assert set(document["song"]["channels"]) == {channel.value for channel in ChannelName.items()}
class TestEmptyProject:
@@ -182,8 +321,8 @@ def test_round_trip_without_instruments(self, tmp_path: Path) -> None:
ProjectContainer.save(project, path)
loaded = ProjectContainer.load(path)
- assert len(loaded.samples) == 0
- assert set(loaded.song.channels) == set(GeneratorName.items())
+ assert len(loaded.voices) == 0
+ assert set(loaded.song.channels) == set(ChannelName.items())
with zipfile.ZipFile(path, "r") as archive:
assert all(not name.startswith(f"{RECONSTRUCTIONS_DIRECTORY}/") for name in archive.namelist())
@@ -286,6 +425,27 @@ def test_unexpected_error_wrapped_as_unhandled(
class TestVersionCompatibility:
+ def test_project_written_at_format_1_0_loads(
+ self,
+ tmp_path: Path,
+ reconstruction_factory: ReconstructionFactory,
+ ) -> None:
+ project = _populated_project(reconstruction_factory)
+ original = tmp_path / "demo.stp"
+ ProjectContainer.save(project, original)
+ legacy = tmp_path / "legacy.stp"
+ _rewrite_document(original, legacy, _as_format_1_0)
+
+ loaded = ProjectContainer.load(legacy)
+
+ assert [voice.id for voice in loaded.voices] == [voice.id for voice in project.voices]
+ assert [voice.name for voice in loaded.voices] == [voice.name for voice in project.voices]
+ assert set(loaded.song.channels) == set(project.song.channels)
+
+ pattern = loaded.song.pattern(ChannelName.PULSE1, loaded.song.order[0][ChannelName.PULSE1])
+ row = pattern.rows[0]
+ assert row.command == NoteOn(voice_id=project.voices[0].id)
+
def test_incompatible_format_version_raises(
self,
tmp_path: Path,
@@ -310,7 +470,7 @@ def test_incompatible_embedded_reconstruction_version_rejected(
) -> None:
"""A project carrying a reconstruction from another build is refused as it opens."""
project = _populated_project(reconstruction_factory)
- project.samples[0].reconstruction = project.samples[0].reconstruction.model_copy(
+ project.voices[0].reconstruction = project.voices[0].reconstruction.model_copy(
update={"metadata": Metadata(reconstruction_data_version="0.0")},
)
path = tmp_path / "demo.stp"
diff --git a/tests/unit/sampletones_core/project/test_models.py b/tests/unit/sampletones_core/project/test_models.py
index b48949bc4..a52c004d4 100644
--- a/tests/unit/sampletones_core/project/test_models.py
+++ b/tests/unit/sampletones_core/project/test_models.py
@@ -3,42 +3,40 @@
import pytest
from pydantic import ValidationError
-from sampletones_core.constants.enums import GeneratorName
-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.voices.note_off import NoteOff
+from sampletones_core.project.voices.note_on import NoteOn
from tests.suite.base import BaseTestSuite
from tests.suite.case import BaseAutolabelTestCase
-def _instrument() -> Instrument:
- return Instrument(
- sample_id="abc123",
- generator_name=GeneratorName.TRIANGLE,
- )
+def _note_on() -> NoteOn:
+ return NoteOn(voice_id="abc123")
-class TestInstrument:
+class TestNoteOn:
def test_is_frozen(self) -> None:
- instrument = _instrument()
+ note_on = _note_on()
with pytest.raises(ValidationError):
- instrument.sample_id = "other" # type: ignore[misc]
+ note_on.voice_id = "other" # type: ignore[misc]
def test_value_equality_and_hash(self) -> None:
- first = _instrument()
- second = _instrument()
+ first = _note_on()
+ second = _note_on()
assert first == second
assert hash(first) == hash(second)
- def test_distinct_slices_differ(self) -> None:
- triangle = Instrument(sample_id="abc", generator_name=GeneratorName.TRIANGLE)
- noise = Instrument(sample_id="abc", generator_name=GeneratorName.NOISE)
- assert triangle != noise
+ def test_distinct_voices_differ(self) -> None:
+ assert NoteOn(voice_id="abc") != NoteOn(voice_id="def")
+
+ def test_names_the_voice_alone(self) -> None:
+ with pytest.raises(ValidationError):
+ NoteOn.model_validate({"voice_id": "abc", "channel_name": "triangle"})
def test_round_trip(self) -> None:
- instrument = _instrument()
- restored = Instrument.model_validate(instrument.model_dump())
- assert restored == instrument
+ note_on = _note_on()
+ restored = NoteOn.model_validate(note_on.model_dump())
+ assert restored == note_on
class TestRowDefaults:
@@ -66,7 +64,7 @@ def label(self) -> str:
test_cases = (
TestCase(expected=Row()),
TestCase(expected=Row(transpose=0, volume=15)),
- TestCase(expected=Row(transpose=12, command=_instrument(), volume=8)),
+ TestCase(expected=Row(transpose=12, command=_note_on(), volume=8)),
TestCase(expected=Row(command=NoteOff())),
)
diff --git a/tests/unit/sampletones_core/project/test_serialization.py b/tests/unit/sampletones_core/project/test_serialization.py
index 3883e2a88..a00e34718 100644
--- a/tests/unit/sampletones_core/project/test_serialization.py
+++ b/tests/unit/sampletones_core/project/test_serialization.py
@@ -1,12 +1,12 @@
import pytest
from pydantic import ValidationError
-from sampletones_core.constants.enums import GeneratorName
-from sampletones_core.project.instruments.instrument import Instrument
+from sampletones_core.constants.enums import ChannelName
from sampletones_core.project.patterns.channel import Channel
from sampletones_core.project.patterns.pattern import Pattern
from sampletones_core.project.patterns.row import Row
from sampletones_core.project.song import Song
+from sampletones_core.project.voices.note_on import NoteOn
from sampletones_shared.constants.project import (
MAX_ROWS_PER_PATTERN,
MIN_ROWS_PER_PATTERN,
@@ -18,10 +18,7 @@ def _pattern_with_instrument() -> Pattern:
pattern.rows[0] = Row(
transpose=0,
volume=15,
- instrument=Instrument(
- sample_id="abc123",
- generator_name=GeneratorName.PULSE1,
- ),
+ instrument=NoteOn(voice_id="abc123"),
)
return pattern
@@ -42,7 +39,7 @@ def test_dump_round_trips_identically(self) -> None:
class TestChannelSerialization:
def _channel(self) -> Channel:
return Channel(
- generator=GeneratorName.PULSE1,
+ name=ChannelName.PULSE1,
patterns={0: Pattern.empty(4, name="a"), 1: Pattern.empty(4, name="b")},
)
@@ -76,12 +73,12 @@ def test_json_round_trip(self) -> None:
def test_channels_preserved(self) -> None:
song = Song.empty(rows_per_pattern=8)
restored = Song.model_validate(song.model_dump())
- assert set(restored.channels) == set(GeneratorName.items())
+ assert set(restored.channels) == set(ChannelName.items())
def test_order_preserved(self) -> None:
song = Song.empty(rows_per_pattern=8)
song.append_frame()
- song.set_order_entry(1, GeneratorName.PULSE1, 0)
+ song.set_order_entry(1, ChannelName.PULSE1, 0)
restored = Song.model_validate(song.model_dump())
assert restored.order == song.order
assert restored.rows_per_pattern == song.rows_per_pattern
diff --git a/tests/unit/sampletones_core/project/test_song.py b/tests/unit/sampletones_core/project/test_song.py
index c6a38264b..d6ee1eed2 100644
--- a/tests/unit/sampletones_core/project/test_song.py
+++ b/tests/unit/sampletones_core/project/test_song.py
@@ -1,10 +1,10 @@
import pytest
from pydantic import ValidationError
-from sampletones_core.constants.enums import GeneratorName
-from sampletones_core.project.instruments.instrument import Instrument
+from sampletones_core.constants.enums import ChannelName
from sampletones_core.project.patterns.row import Row
from sampletones_core.project.song import Song
+from sampletones_core.project.voices.note_on import NoteOn
from sampletones_shared.constants.project import (
DEFAULT_ROWS_PER_PATTERN,
MAX_ROWS_PER_PATTERN,
@@ -18,10 +18,10 @@ def _song(rows_per_pattern: int = _ROWS) -> Song:
return Song.empty(rows_per_pattern)
-def _place_instrument(song: Song, generator: GeneratorName, sample_id: str, row_index: int = 0) -> None:
- pattern = song.pattern(generator, 0)
+def _place_voice(song: Song, channel: ChannelName, voice_id: str, row_index: int = 0) -> None:
+ pattern = song.pattern(channel, 0)
assert pattern is not None
- pattern.rows[row_index] = Row(command=Instrument(sample_id=sample_id, generator_name=generator))
+ pattern.rows[row_index] = Row(command=NoteOn(voice_id=voice_id))
class TestSongEmpty:
@@ -32,13 +32,13 @@ def test_creates_one_frame_order(self) -> None:
def test_first_frame_maps_all_channels_to_zero(self) -> None:
song = _song()
frame = song.order[0]
- for generator in GeneratorName.items():
- assert frame[generator] == 0
+ for channel in ChannelName.items():
+ assert frame[channel] == 0
def test_all_channels_present(self) -> None:
song = _song()
- for generator in GeneratorName.items():
- assert generator in song.channels
+ for channel in ChannelName.items():
+ assert channel in song.channels
def test_rows_per_pattern_stored(self) -> None:
song = Song.empty(16)
@@ -48,20 +48,20 @@ def test_rows_per_pattern_stored(self) -> None:
class TestSongGetItem:
def test_getitem_returns_channel(self) -> None:
song = _song()
- channel = song[GeneratorName.PULSE1]
- assert channel.generator == GeneratorName.PULSE1
+ channel = song[ChannelName.PULSE1]
+ assert channel.name == ChannelName.PULSE1
class TestSongPattern:
def test_pattern_returns_pattern_at_index(self) -> None:
song = _song()
- pattern = song.pattern(GeneratorName.PULSE1, 0)
+ pattern = song.pattern(ChannelName.PULSE1, 0)
assert pattern is not None
assert len(pattern.rows) == _ROWS
def test_pattern_returns_none_for_missing_index(self) -> None:
song = _song()
- assert song.pattern(GeneratorName.PULSE1, 99) is None
+ assert song.pattern(ChannelName.PULSE1, 99) is None
class TestSongOrderLength:
@@ -76,8 +76,8 @@ def test_append_adds_frame_with_all_none(self) -> None:
song = _song()
song.append_frame()
frame = song.order[1]
- for generator in GeneratorName.items():
- assert frame[generator] is None
+ for channel in ChannelName.items():
+ assert frame[channel] is None
def test_append_increments_order_length(self) -> None:
song = _song()
@@ -91,16 +91,16 @@ def test_insert_at_zero_shifts_existing_frame(self) -> None:
song = _song()
song.insert_frame(0)
assert song.order_length() == 2
- for generator in GeneratorName.items():
- assert song.order[0][generator] is None
- assert song.order[1][generator] == 0
+ for channel in ChannelName.items():
+ assert song.order[0][channel] is None
+ assert song.order[1][channel] == 0
def test_insert_at_end_appends_none_frame(self) -> None:
song = _song()
song.insert_frame(1)
assert song.order_length() == 2
- for generator in GeneratorName.items():
- assert song.order[1][generator] is None
+ for channel in ChannelName.items():
+ assert song.order[1][channel] is None
class TestSongRemoveFrame:
@@ -113,9 +113,9 @@ def test_remove_frame_decrements_order_length(self) -> None:
def test_remove_frame_at_zero_removes_first(self) -> None:
song = _song()
song.append_frame()
- song.set_order_entry(1, GeneratorName.PULSE1, 3)
+ song.set_order_entry(1, ChannelName.PULSE1, 3)
song.remove_frame(0)
- assert song.order[0][GeneratorName.PULSE1] == 3
+ assert song.order[0][ChannelName.PULSE1] == 3
def test_remove_only_frame_leaves_empty_order(self) -> None:
song = _song()
@@ -126,13 +126,13 @@ def test_remove_only_frame_leaves_empty_order(self) -> None:
class TestSongSetOrderEntry:
def test_set_order_entry_updates_value(self) -> None:
song = _song()
- song.set_order_entry(0, GeneratorName.TRIANGLE, 5)
- assert song.order[0][GeneratorName.TRIANGLE] == 5
+ song.set_order_entry(0, ChannelName.TRIANGLE, 5)
+ assert song.order[0][ChannelName.TRIANGLE] == 5
def test_set_order_entry_to_none(self) -> None:
song = _song()
- song.set_order_entry(0, GeneratorName.PULSE1, None)
- assert song.order[0][GeneratorName.PULSE1] is None
+ song.set_order_entry(0, ChannelName.PULSE1, None)
+ assert song.order[0][ChannelName.PULSE1] is None
class TestSongMoveFrame:
@@ -140,60 +140,60 @@ def test_move_frame_reorders(self) -> None:
song = _song()
song.append_frame()
song.append_frame()
- song.set_order_entry(0, GeneratorName.PULSE1, 1)
- song.set_order_entry(1, GeneratorName.PULSE1, 2)
- song.set_order_entry(2, GeneratorName.PULSE1, 3)
+ song.set_order_entry(0, ChannelName.PULSE1, 1)
+ song.set_order_entry(1, ChannelName.PULSE1, 2)
+ song.set_order_entry(2, ChannelName.PULSE1, 3)
song.move_frame(0, 2)
- assert song.order[0][GeneratorName.PULSE1] == 2
- assert song.order[1][GeneratorName.PULSE1] == 3
- assert song.order[2][GeneratorName.PULSE1] == 1
+ assert song.order[0][ChannelName.PULSE1] == 2
+ assert song.order[1][ChannelName.PULSE1] == 3
+ assert song.order[2][ChannelName.PULSE1] == 1
def test_move_frame_no_op_when_same_position(self) -> None:
song = _song()
- song.set_order_entry(0, GeneratorName.PULSE1, 7)
+ song.set_order_entry(0, ChannelName.PULSE1, 7)
song.move_frame(0, 0)
- assert song.order[0][GeneratorName.PULSE1] == 7
+ assert song.order[0][ChannelName.PULSE1] == 7
class TestSongDuplicateFrame:
def test_duplicate_inserts_frame_after_position(self) -> None:
song = _song()
song.append_frame()
- song.set_order_entry(1, GeneratorName.PULSE1, 3)
+ song.set_order_entry(1, ChannelName.PULSE1, 3)
song.duplicate_frame(0)
assert song.order_length() == 3
- assert song.order[2][GeneratorName.PULSE1] == 3
+ assert song.order[2][ChannelName.PULSE1] == 3
def test_duplicate_points_channels_at_the_same_patterns(self) -> None:
song = _song()
song.duplicate_frame(0)
- source_index = song.order[0][GeneratorName.PULSE1]
- duplicate_index = song.order[1][GeneratorName.PULSE1]
+ source_index = song.order[0][ChannelName.PULSE1]
+ duplicate_index = song.order[1][ChannelName.PULSE1]
assert duplicate_index == source_index
def test_duplicate_allocates_no_pattern(self) -> None:
song = _song()
- pattern_count = len(song[GeneratorName.PULSE1].patterns)
+ pattern_count = len(song[ChannelName.PULSE1].patterns)
song.duplicate_frame(0)
- assert len(song[GeneratorName.PULSE1].patterns) == pattern_count
+ assert len(song[ChannelName.PULSE1].patterns) == pattern_count
def test_editing_a_shared_pattern_is_heard_in_both_frames(self) -> None:
song = _song()
song.duplicate_frame(0)
- duplicate_index = song.order[1][GeneratorName.PULSE1]
+ duplicate_index = song.order[1][ChannelName.PULSE1]
assert duplicate_index is not None
- _place_instrument(song, GeneratorName.PULSE1, "sample-a", row_index=0)
+ _place_voice(song, ChannelName.PULSE1, "sample-a", row_index=0)
- shared_pattern = song.pattern(GeneratorName.PULSE1, duplicate_index)
+ shared_pattern = song.pattern(ChannelName.PULSE1, duplicate_index)
assert shared_pattern is not None
assert shared_pattern.rows[0].command is not None
@@ -202,88 +202,88 @@ def test_repointing_one_frame_leaves_the_other_where_it_was(self) -> None:
song = _song()
song.duplicate_frame(0)
- song.set_order_entry(1, GeneratorName.PULSE1, 9)
+ song.set_order_entry(1, ChannelName.PULSE1, 9)
- assert song.order[0][GeneratorName.PULSE1] == 0
+ assert song.order[0][ChannelName.PULSE1] == 0
def test_duplicate_carries_an_unmaterialised_index_across(self) -> None:
song = _song()
- song.set_order_entry(0, GeneratorName.PULSE1, 7)
+ song.set_order_entry(0, ChannelName.PULSE1, 7)
song.duplicate_frame(0)
- assert song.order[1][GeneratorName.PULSE1] == 7
- assert song.pattern(GeneratorName.PULSE1, 7) is None
+ assert song.order[1][ChannelName.PULSE1] == 7
+ assert song.pattern(ChannelName.PULSE1, 7) is None
class TestSongCloneFrame:
def test_clone_inserts_frame_after_position(self) -> None:
song = _song()
song.append_frame()
- song.set_order_entry(1, GeneratorName.PULSE1, 3)
+ song.set_order_entry(1, ChannelName.PULSE1, 3)
song.clone_frame(0)
assert song.order_length() == 3
- assert song.order[2][GeneratorName.PULSE1] == 3
+ assert song.order[2][ChannelName.PULSE1] == 3
def test_clone_points_channels_at_fresh_patterns(self) -> None:
song = _song()
song.clone_frame(0)
- source_index = song.order[0][GeneratorName.PULSE1]
- clone_index = song.order[1][GeneratorName.PULSE1]
+ source_index = song.order[0][ChannelName.PULSE1]
+ clone_index = song.order[1][ChannelName.PULSE1]
assert clone_index != source_index
def test_clone_avoids_indices_referenced_by_other_frames(self) -> None:
song = _song()
song.append_frame()
- song.set_order_entry(1, GeneratorName.PULSE1, 7)
+ song.set_order_entry(1, ChannelName.PULSE1, 7)
song.clone_frame(0)
- clone_index = song.order[1][GeneratorName.PULSE1]
+ clone_index = song.order[1][ChannelName.PULSE1]
assert clone_index != 7
def test_editing_a_cloned_pattern_leaves_the_source_untouched(self) -> None:
song = _song()
- _place_instrument(song, GeneratorName.PULSE1, "sample-a", row_index=0)
- source_index = song.order[0][GeneratorName.PULSE1]
+ _place_voice(song, ChannelName.PULSE1, "sample-a", row_index=0)
+ source_index = song.order[0][ChannelName.PULSE1]
song.clone_frame(0)
- clone_index = song.order[1][GeneratorName.PULSE1]
- song[GeneratorName.PULSE1].set_row(clone_index, 0, Row())
+ clone_index = song.order[1][ChannelName.PULSE1]
+ song[ChannelName.PULSE1].set_row(clone_index, 0, Row())
- source_pattern = song.pattern(GeneratorName.PULSE1, source_index)
+ source_pattern = song.pattern(ChannelName.PULSE1, source_index)
assert source_pattern is not None
assert source_pattern.rows[0].command is not None
def test_clone_keeps_a_silent_slot_silent(self) -> None:
song = _song()
- song.set_order_entry(0, GeneratorName.NOISE, None)
+ song.set_order_entry(0, ChannelName.NOISE, None)
song.clone_frame(0)
- assert song.order[1][GeneratorName.NOISE] is None
+ assert song.order[1][ChannelName.NOISE] is None
class TestSongPatternAllocation:
def test_add_pattern_skips_indices_referenced_by_the_order(self) -> None:
song = _song()
- song.set_order_entry(0, GeneratorName.PULSE1, 4)
+ song.set_order_entry(0, ChannelName.PULSE1, 4)
- index = song.add_pattern(GeneratorName.PULSE1)
+ index = song.add_pattern(ChannelName.PULSE1)
assert index != 4
- assert index in song[GeneratorName.PULSE1].patterns
+ assert index in song[ChannelName.PULSE1].patterns
def test_clone_pattern_skips_indices_referenced_by_the_order(self) -> None:
song = _song()
song.append_frame()
- song.set_order_entry(1, GeneratorName.PULSE1, 6)
+ song.set_order_entry(1, ChannelName.PULSE1, 6)
- clone_index = song.clone_pattern(GeneratorName.PULSE1, 0)
+ clone_index = song.clone_pattern(ChannelName.PULSE1, 0)
assert clone_index != 6
@@ -291,43 +291,43 @@ def test_clone_pattern_skips_indices_referenced_by_the_order(self) -> None:
class TestSongClearFrame:
def test_clear_frame_sets_all_channels_to_none(self) -> None:
song = _song()
- for generator in GeneratorName.items():
- song.set_order_entry(0, generator, 3)
+ for channel in ChannelName.items():
+ song.set_order_entry(0, channel, 3)
song.clear_frame(0)
- assert all(song.order[0][generator] is None for generator in GeneratorName.items())
+ assert all(song.order[0][channel] is None for channel in ChannelName.items())
def test_clear_frame_leaves_other_frames(self) -> None:
song = _song()
song.append_frame()
- song.set_order_entry(0, GeneratorName.PULSE1, 1)
- song.set_order_entry(1, GeneratorName.PULSE1, 2)
+ song.set_order_entry(0, ChannelName.PULSE1, 1)
+ song.set_order_entry(1, ChannelName.PULSE1, 2)
song.clear_frame(0)
- assert song.order[0][GeneratorName.PULSE1] is None
- assert song.order[1][GeneratorName.PULSE1] == 2
+ assert song.order[0][ChannelName.PULSE1] is None
+ assert song.order[1][ChannelName.PULSE1] == 2
class TestSongOrderedPatterns:
def test_ordered_patterns_returns_pattern_objects(self) -> None:
song = _song()
- patterns = song.ordered_patterns(GeneratorName.PULSE1)
+ patterns = song.ordered_patterns(ChannelName.PULSE1)
assert len(patterns) == 1
assert patterns[0] is not None
def test_ordered_patterns_returns_none_for_none_slot(self) -> None:
song = _song()
song.append_frame()
- patterns = song.ordered_patterns(GeneratorName.PULSE1)
+ patterns = song.ordered_patterns(ChannelName.PULSE1)
assert patterns[1] is None
def test_ordered_patterns_same_index_repeated_returns_same_object(self) -> None:
song = _song()
song.append_frame()
- song.set_order_entry(1, GeneratorName.PULSE1, 0)
- patterns = song.ordered_patterns(GeneratorName.PULSE1)
+ song.set_order_entry(1, ChannelName.PULSE1, 0)
+ patterns = song.ordered_patterns(ChannelName.PULSE1)
assert patterns[0] is patterns[1]
@@ -335,68 +335,68 @@ class TestSongRemovePattern:
def test_remove_pattern_clears_order_references(self) -> None:
song = _song()
song.append_frame()
- song.set_order_entry(1, GeneratorName.PULSE1, 0)
+ song.set_order_entry(1, ChannelName.PULSE1, 0)
- song.remove_pattern(GeneratorName.PULSE1, 0)
+ song.remove_pattern(ChannelName.PULSE1, 0)
- assert song.order[0][GeneratorName.PULSE1] is None
- assert song.order[1][GeneratorName.PULSE1] is None
+ assert song.order[0][ChannelName.PULSE1] is None
+ assert song.order[1][ChannelName.PULSE1] is None
def test_remove_pattern_does_not_affect_other_channels(self) -> None:
song = _song()
- song.remove_pattern(GeneratorName.PULSE1, 0)
- assert song.order[0][GeneratorName.TRIANGLE] == 0
+ song.remove_pattern(ChannelName.PULSE1, 0)
+ assert song.order[0][ChannelName.TRIANGLE] == 0
def test_remove_nonexistent_pattern_raises(self) -> None:
song = _song()
with pytest.raises(KeyError):
- song.remove_pattern(GeneratorName.PULSE1, 99)
+ song.remove_pattern(ChannelName.PULSE1, 99)
class TestSongReferencesSample:
def test_false_when_no_row_references_any_sample(self) -> None:
- assert _song().references_sample("abc") is False
+ assert _song().references_voice("abc") is False
def test_true_when_a_row_references_the_sample(self) -> None:
song = _song()
- _place_instrument(song, GeneratorName.PULSE1, "abc")
- assert song.references_sample("abc") is True
+ _place_voice(song, ChannelName.PULSE1, "abc")
+ assert song.references_voice("abc") is True
- def test_false_for_a_different_sample_id(self) -> None:
+ def test_false_for_a_different_voice_id(self) -> None:
song = _song()
- _place_instrument(song, GeneratorName.PULSE1, "abc")
- assert song.references_sample("xyz") is False
+ _place_voice(song, ChannelName.PULSE1, "abc")
+ assert song.references_voice("xyz") is False
class TestSongClearSampleReferences:
def test_clears_only_rows_referencing_the_target(self) -> None:
song = _song()
- _place_instrument(song, GeneratorName.PULSE1, "abc", row_index=0)
- _place_instrument(song, GeneratorName.PULSE1, "keep", row_index=1)
+ _place_voice(song, ChannelName.PULSE1, "abc", row_index=0)
+ _place_voice(song, ChannelName.PULSE1, "keep", row_index=1)
- song.clear_sample_references("abc")
+ song.clear_voice_references("abc")
- pattern = song.pattern(GeneratorName.PULSE1, 0)
+ pattern = song.pattern(ChannelName.PULSE1, 0)
assert pattern is not None
assert pattern.rows[0].command is None
assert pattern.rows[1].command is not None
def test_clears_references_across_all_channels(self) -> None:
song = _song()
- _place_instrument(song, GeneratorName.PULSE1, "abc")
- _place_instrument(song, GeneratorName.TRIANGLE, "abc")
+ _place_voice(song, ChannelName.PULSE1, "abc")
+ _place_voice(song, ChannelName.TRIANGLE, "abc")
- song.clear_sample_references("abc")
+ song.clear_voice_references("abc")
- assert song.references_sample("abc") is False
+ assert song.references_voice("abc") is False
def test_leaves_rows_untouched_when_sample_absent(self) -> None:
song = _song()
- _place_instrument(song, GeneratorName.PULSE1, "abc")
+ _place_voice(song, ChannelName.PULSE1, "abc")
- song.clear_sample_references("missing")
+ song.clear_voice_references("missing")
- pattern = song.pattern(GeneratorName.PULSE1, 0)
+ pattern = song.pattern(ChannelName.PULSE1, 0)
assert pattern is not None
assert pattern.rows[0].command is not None
diff --git a/tests/unit/sampletones_core/project/test_structure.py b/tests/unit/sampletones_core/project/test_structure.py
index db7ffb9f0..630634b29 100644
--- a/tests/unit/sampletones_core/project/test_structure.py
+++ b/tests/unit/sampletones_core/project/test_structure.py
@@ -2,14 +2,14 @@
from typing import Dict
from unittest.mock import Mock
-from sampletones_core.constants.enums import GeneratorName
-from sampletones_core.project.instruments.instrument import Instrument
-from sampletones_core.project.instruments.sample import Sample
+from sampletones_core.constants.enums import ChannelName
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.song import Song
+from sampletones_core.project.voices.note_on import NoteOn
+from sampletones_core.project.voices.sample import Sample
from tests.suite.scenario import BaseTestScenario, ScenarioStep
@@ -27,48 +27,48 @@ def test_empty_pattern(self) -> None:
class TestChannel:
def test_empty_channel(self) -> None:
- channel = Channel.empty(GeneratorName.PULSE1, rows_per_pattern=16)
- assert channel.generator == GeneratorName.PULSE1
+ channel = Channel.empty(ChannelName.PULSE1, rows_per_pattern=16)
+ assert channel.name == ChannelName.PULSE1
assert len(channel.patterns) == 1
assert 0 in channel.patterns
def test_pattern_resolution(self) -> None:
- channel = Channel.empty(GeneratorName.NOISE, rows_per_pattern=4)
+ channel = Channel.empty(ChannelName.NOISE, rows_per_pattern=4)
assert channel.pattern(0) is channel.patterns[0]
def test_unknown_pattern_returns_none(self) -> None:
- channel = Channel.empty(GeneratorName.NOISE, rows_per_pattern=4)
+ channel = Channel.empty(ChannelName.NOISE, rows_per_pattern=4)
assert channel.pattern(99) is None
class TestSong:
def test_empty_song_has_all_channels(self) -> None:
song = Song.empty(rows_per_pattern=8)
- assert set(song.channels) == set(GeneratorName.items())
- for generator in GeneratorName.items():
- assert song[generator].generator == generator
+ assert set(song.channels) == set(ChannelName.items())
+ for channel in ChannelName.items():
+ assert song[channel].name == channel
class TestProject:
def test_create(self) -> None:
project = Project.create(title="Demo")
assert project.info.title == "Demo"
- assert len(project.samples) == 0
- assert set(project.song.channels) == set(GeneratorName.items())
+ assert len(project.voices) == 0
+ assert set(project.song.channels) == set(ChannelName.items())
def test_instrument_resolution(self) -> None:
project = Project.create()
sample = _sample("lead")
- project.samples.append(sample)
- assert project.sample(sample.id) is sample
- assert project.sample("missing") is None
+ project.voices.append(sample)
+ assert project.voice(sample.id) is sample
+ assert project.voice("missing") is None
@dataclass
class SampleContext:
project: Project
sample: Sample
- instrument: Instrument
+ instrument: NoteOn
resolved: Dict[str, Sample] = field(default_factory=dict)
@@ -80,22 +80,22 @@ def _build_instrument_context(self) -> SampleContext:
project = Project.create()
first = _sample("first")
second = _sample("second")
- project.samples.extend([first, second])
- instrument = Instrument(sample_id=first.id, generator_name=GeneratorName.PULSE1)
+ project.voices.extend([first, second])
+ instrument = NoteOn(voice_id=first.id)
return SampleContext(project=project, sample=first, instrument=instrument)
def test_instrument_survives_instrument_reorder(self) -> None:
def check_before(context: SampleContext) -> None:
- assert context.project.samples.index(context.sample) == 0
- context.resolved["before"] = context.project.sample(context.instrument.sample_id)
+ assert context.project.voices.index(context.sample) == 0
+ context.resolved["before"] = context.project.voice(context.instrument.voice_id)
def reorder(context: SampleContext) -> None:
- moved = context.project.samples.pop(0)
- context.project.samples.append(moved)
- assert context.project.samples.index(context.sample) == 1
+ moved = context.project.voices.pop(0)
+ context.project.voices.append(moved)
+ assert context.project.voices.index(context.sample) == 1
def check_after(context: SampleContext) -> None:
- resolved = context.project.sample(context.instrument.sample_id)
+ resolved = context.project.voice(context.instrument.voice_id)
assert resolved is context.sample
assert resolved is context.resolved["before"]
diff --git a/tests/unit/sampletones_core/project/test_tuning.py b/tests/unit/sampletones_core/project/test_tuning.py
new file mode 100644
index 000000000..1e422331d
--- /dev/null
+++ b/tests/unit/sampletones_core/project/test_tuning.py
@@ -0,0 +1,63 @@
+from typing import Final
+
+import pytest
+
+from sampletones_core.project.project import Project
+from sampletones_core.project.tuning import UNTUNED_PROJECT, tuning_from_project
+from sampletones_core.project.voices.sample import Sample
+from sampletones_shared.music import Tuning
+from tests.suite.performance import (
+ make_pulse_reconstruction,
+ make_triangle_reconstruction,
+ project_with_sample,
+ retuned_reconstruction,
+)
+
+ROWS_PER_PATTERN: Final[int] = 4
+CONCERT_PITCH: Final[float] = 440.0
+BAROQUE_PITCH: Final[float] = 415.0
+
+
+def sampled_project() -> Project:
+ project, _ = project_with_sample(
+ make_pulse_reconstruction(pitch=60, count=2),
+ rows_per_pattern=ROWS_PER_PATTERN,
+ )
+ return project
+
+
+class TestTheTuningAProjectSounds:
+ """A project carries no tuning of its own, so its samples state it between them."""
+
+ def test_a_projects_sample_states_the_tuning(self) -> None:
+ project = sampled_project()
+ assert tuning_from_project(project) == project.voices[0].reconstruction.config.tuning
+
+ def test_samples_agreeing_state_the_tuning_they_agree_on(self) -> None:
+ project = sampled_project()
+ project.voices.append(
+ Sample(
+ name="second",
+ reconstruction=make_triangle_reconstruction(pitch=45, count=2),
+ )
+ )
+ assert tuning_from_project(project) == Tuning(a4_frequency=CONCERT_PITCH)
+
+ def test_a_project_holding_no_samples_sounds_the_default_tuning(self) -> None:
+ """A project with nothing to sound still answers, so an empty song reaches the console."""
+ assert tuning_from_project(Project.create(rows_per_pattern=ROWS_PER_PATTERN)) == UNTUNED_PROJECT
+
+ def test_samples_that_disagree_are_refused(self) -> None:
+ """One timer table sounds one tuning, so a project holding two of them names both."""
+ project = sampled_project()
+ project.voices.append(
+ Sample(
+ name="baroque",
+ reconstruction=retuned_reconstruction(
+ make_triangle_reconstruction(pitch=45, count=2),
+ BAROQUE_PITCH,
+ ),
+ )
+ )
+ with pytest.raises(ValueError, match=str(BAROQUE_PITCH)):
+ tuning_from_project(project)
diff --git a/tests/unit/sampletones_core/project/voices/__init__.py b/tests/unit/sampletones_core/project/voices/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/tests/unit/sampletones_core/project/voices/test_creation.py b/tests/unit/sampletones_core/project/voices/test_creation.py
new file mode 100644
index 000000000..f30a3b341
--- /dev/null
+++ b/tests/unit/sampletones_core/project/voices/test_creation.py
@@ -0,0 +1,154 @@
+from typing import Optional, Tuple
+
+import numpy as np
+
+from sampletones_core.constants.enums import ChannelName
+from sampletones_core.constants.general import MAX_VOLUME
+from sampletones_core.exporters import Features
+from sampletones_core.features import RESTING_REFERENCE_PERIOD, RESTING_REFERENCE_PITCH
+from sampletones_core.features.envelope import Envelope
+from sampletones_core.project.voices.creation import instrument_from_features, new_instrument
+
+TONAL_REFERENCE = 64
+NOISE_REFERENCE = 5
+VOLUME = (15, 12, 9)
+ARPEGGIO = (0, 4, 7)
+DUTY_CYCLE = (2, 2, 1)
+
+
+def _features(
+ reference: int,
+ *,
+ duty_cycle: Optional[Tuple[int, ...]] = DUTY_CYCLE,
+ volume: Tuple[int, ...] = VOLUME,
+) -> Features:
+ """What one channel plays, as its exporter states it."""
+ return Features(
+ initial_pitch=reference,
+ volume=Envelope(items=tuple(volume)),
+ arpeggio=Envelope(items=tuple(ARPEGGIO)),
+ pitch=None,
+ hi_pitch=None,
+ duty_cycle=None if duty_cycle is None else Envelope(items=tuple(duty_cycle)),
+ )
+
+
+class TestNewInstrument:
+ def test_a_new_instrument_sounds_a_frame_on_every_channel(self) -> None:
+ instrument = new_instrument("lead")
+
+ for channel_name in ChannelName.items():
+ assert instrument.instructions(channel_name)
+
+ def test_a_new_instrument_sounds_at_full_volume(self) -> None:
+ instrument = new_instrument("lead")
+
+ assert instrument.envelopes.volume.items == (MAX_VOLUME,)
+
+ def test_a_new_instrument_repeats_its_volume_while_the_note_is_held(self) -> None:
+ assert new_instrument("lead").envelopes.volume.loop_point == 0
+
+ def test_a_new_instrument_leaves_the_arpeggio_and_the_duty_cycle_to_the_channel(self) -> None:
+ instrument = new_instrument("lead")
+
+ assert not instrument.envelopes.arpeggio.written
+ assert not instrument.envelopes.duty_cycle.written
+
+ def test_a_new_instrument_rests_where_a_channel_added_by_hand_rests(self) -> None:
+ instrument = new_instrument("lead")
+
+ assert instrument.initial_pitch == RESTING_REFERENCE_PITCH
+ assert instrument.initial_period == RESTING_REFERENCE_PERIOD
+
+ def test_each_new_instrument_is_a_voice_of_its_own(self) -> None:
+ assert new_instrument("lead").id != new_instrument("lead").id
+
+
+class TestAnInstrumentTakenFromAChannel:
+ """A recording's channel is read back as envelopes, so what it played becomes editable."""
+
+ def test_the_envelopes_come_across_as_the_channel_played_them(self) -> None:
+ instrument = instrument_from_features(
+ "Bass (pulse1)",
+ _features(TONAL_REFERENCE),
+ ChannelName.PULSE1,
+ )
+
+ assert instrument.envelopes.volume.items == VOLUME
+ assert instrument.envelopes.arpeggio.items == ARPEGGIO
+ assert instrument.envelopes.duty_cycle.items == DUTY_CYCLE
+
+ def test_a_dimension_the_channel_governs_stays_the_channels(self) -> None:
+ """An empty envelope means the channel keeps the value it holds, on either side of this."""
+ instrument = instrument_from_features(
+ "Bass (pulse1)",
+ _features(TONAL_REFERENCE, volume=()),
+ ChannelName.PULSE1,
+ )
+
+ assert instrument.envelopes.volume.items == ()
+
+ def test_a_dimension_the_channel_lacks_is_left_unwritten(self) -> None:
+ """The triangle channel offers no duty cycle, so the voice writes none for it."""
+ instrument = instrument_from_features(
+ "Bass (triangle)",
+ _features(TONAL_REFERENCE, duty_cycle=None),
+ ChannelName.TRIANGLE,
+ )
+
+ assert instrument.envelopes.duty_cycle.items == ()
+
+ def test_a_tonal_channels_reference_becomes_the_note_the_arpeggio_is_measured_against(self) -> None:
+ instrument = instrument_from_features(
+ "Bass (pulse1)",
+ _features(TONAL_REFERENCE),
+ ChannelName.PULSE1,
+ )
+
+ assert instrument.initial_pitch == TONAL_REFERENCE
+ assert instrument.initial_period == RESTING_REFERENCE_PERIOD
+
+ def test_the_noise_channels_reference_becomes_the_period_the_arpeggio_is_measured_against(self) -> None:
+ instrument = instrument_from_features(
+ "Bass (noise)",
+ _features(NOISE_REFERENCE),
+ ChannelName.NOISE,
+ )
+
+ assert instrument.initial_period == NOISE_REFERENCE
+ assert instrument.initial_pitch == RESTING_REFERENCE_PITCH
+
+ def test_the_voice_reads_on_its_own_channel_what_that_channel_stated(self) -> None:
+ """The reference travels with the envelopes, so the two agree where they came from."""
+ features = _features(TONAL_REFERENCE)
+
+ instrument = instrument_from_features("Bass (pulse1)", features, ChannelName.PULSE1)
+
+ assert instrument.features(ChannelName.PULSE1).initial_pitch == features.initial_pitch
+
+ def test_the_voice_sounds_the_frames_the_channel_sounded(self) -> None:
+ instrument = instrument_from_features(
+ "Bass (pulse1)",
+ _features(TONAL_REFERENCE),
+ ChannelName.PULSE1,
+ )
+
+ assert len(instrument.instructions(ChannelName.PULSE1)) == len(VOLUME)
+
+ def test_the_voice_holds_each_dimension_out_past_its_items(self) -> None:
+ instrument = instrument_from_features(
+ "Bass (pulse1)",
+ _features(TONAL_REFERENCE),
+ ChannelName.PULSE1,
+ )
+
+ assert all(envelope.loop_point is None for envelope in instrument.envelopes.envelope_map.values())
+
+ def test_the_voice_carries_the_name_it_was_given(self) -> None:
+ instrument = instrument_from_features(
+ "Bass (pulse1)",
+ _features(TONAL_REFERENCE),
+ ChannelName.PULSE1,
+ )
+
+ assert instrument.name == "Bass (pulse1)"
diff --git a/tests/unit/sampletones_core/project/voices/test_instrument.py b/tests/unit/sampletones_core/project/voices/test_instrument.py
new file mode 100644
index 000000000..ec452b8c3
--- /dev/null
+++ b/tests/unit/sampletones_core/project/voices/test_instrument.py
@@ -0,0 +1,184 @@
+from dataclasses import dataclass
+from typing import Tuple
+
+import pytest
+from pydantic import ValidationError
+
+from sampletones_core.constants.enums import ChannelName, FeatureKey
+from sampletones_core.constants.general import MAX_VOLUME
+from sampletones_core.features import (
+ RESTING_REFERENCE_PERIOD,
+ RESTING_REFERENCE_PITCH,
+ supported_features,
+ supports,
+)
+from sampletones_core.features.envelope import Envelope
+from sampletones_core.features.spec import CHANNEL_GENERATOR_KIND
+from sampletones_core.instructions import NoiseInstruction, PulseInstruction, TriangleInstruction
+from sampletones_core.project.voices.envelopes import InstrumentEnvelopes
+from sampletones_core.project.voices.instrument import Instrument
+from tests.suite.base import BaseTestSuite
+from tests.suite.case import BaseRegularTestCase
+
+VOLUME: Tuple[int, ...] = (15, 12, 9, 6)
+ARPEGGIO: Tuple[int, ...] = (0, 0, 12, 12)
+DUTY_CYCLE: Tuple[int, ...] = (2,)
+
+
+def _instrument(**overrides: object) -> Instrument:
+ fields: dict = {
+ "name": "lead",
+ "envelopes": InstrumentEnvelopes(
+ volume=Envelope(items=VOLUME), arpeggio=Envelope(items=ARPEGGIO), duty_cycle=Envelope(items=DUTY_CYCLE)
+ ),
+ }
+ fields.update(overrides)
+ return Instrument(**fields)
+
+
+class TestInstrumentIdentity:
+ def test_each_instrument_gets_its_own_id(self) -> None:
+ assert _instrument().id != _instrument().id
+
+ def test_clone_gets_a_fresh_id_and_carries_the_rest(self) -> None:
+ instrument = _instrument(initial_pitch=48, initial_period=3)
+ clone = instrument.clone()
+
+ assert clone.id != instrument.id
+ assert clone.name == instrument.name
+ assert clone.envelopes == instrument.envelopes
+ assert clone.initial_pitch == instrument.initial_pitch
+ assert clone.initial_period == instrument.initial_period
+
+
+class TestInstrumentRoots:
+ def test_an_instrument_rests_where_a_channel_added_by_hand_rests(self) -> None:
+ instrument = Instrument(name="lead")
+
+ assert instrument.initial_pitch == RESTING_REFERENCE_PITCH
+ assert instrument.initial_period == RESTING_REFERENCE_PERIOD
+
+ def test_the_tonal_channels_read_the_pitch_and_noise_reads_the_period(self) -> None:
+ instrument = _instrument(initial_pitch=55, initial_period=3)
+
+ assert instrument.reference(ChannelName.PULSE1) == 55
+ assert instrument.reference(ChannelName.PULSE2) == 55
+ assert instrument.reference(ChannelName.TRIANGLE) == 55
+ assert instrument.reference(ChannelName.NOISE) == 3
+
+
+class TestInstrumentFeatures(BaseTestSuite):
+ """One set of envelopes, read on every channel in the dimensions that channel offers."""
+
+ @dataclass(frozen=True, kw_only=True)
+ class TestCase(BaseRegularTestCase):
+ channel_name: ChannelName
+
+ test_cases = (
+ TestCase(label=ChannelName.PULSE1.value, channel_name=ChannelName.PULSE1),
+ TestCase(label=ChannelName.PULSE2.value, channel_name=ChannelName.PULSE2),
+ TestCase(label=ChannelName.TRIANGLE.value, channel_name=ChannelName.TRIANGLE),
+ TestCase(label=ChannelName.NOISE.value, channel_name=ChannelName.NOISE),
+ )
+
+ @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label)
+ def test_the_channel_reads_the_dimensions_it_offers(self, test_case: TestCase) -> None:
+ features = _instrument().features(test_case.channel_name)
+ kind = CHANNEL_GENERATOR_KIND[test_case.channel_name]
+
+ assert set(features.envelopes) >= set(supported_features(kind))
+ assert (features.duty_cycle is not None) is supports(kind, FeatureKey.DUTY_CYCLE)
+
+ @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label)
+ def test_the_arpeggio_is_measured_against_the_channels_root(self, test_case: TestCase) -> None:
+ instrument = _instrument()
+
+ assert instrument.features(test_case.channel_name).initial_pitch == instrument.reference(test_case.channel_name)
+
+ @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label)
+ def test_every_channel_sounds_one_frame_per_tick(self, test_case: TestCase) -> None:
+ instrument = _instrument()
+
+ assert len(instrument.instructions(test_case.channel_name)) == instrument.envelopes.frame_count
+
+
+class TestInstrumentInstructions:
+ def test_a_pulse_frame_carries_the_volume_duty_and_root(self) -> None:
+ first = _instrument().instructions(ChannelName.PULSE1)[0]
+
+ assert first == PulseInstruction(
+ on=True,
+ pitch=RESTING_REFERENCE_PITCH,
+ volume=VOLUME[0],
+ duty_cycle=DUTY_CYCLE[0],
+ )
+
+ def test_the_arpeggio_moves_the_frame_off_the_root(self) -> None:
+ instructions = _instrument().instructions(ChannelName.PULSE1)
+ third = instructions[2]
+
+ assert isinstance(third, PulseInstruction)
+ assert third.pitch == RESTING_REFERENCE_PITCH + ARPEGGIO[2]
+
+ def test_a_triangle_frame_sounds_at_the_root(self) -> None:
+ first = _instrument().instructions(ChannelName.TRIANGLE)[0]
+
+ assert first == TriangleInstruction(on=True, pitch=RESTING_REFERENCE_PITCH)
+
+ def test_a_noise_frame_takes_the_period_root_and_the_short_mode(self) -> None:
+ first = _instrument().instructions(ChannelName.NOISE)[0]
+
+ assert first == NoiseInstruction(
+ on=True,
+ period=RESTING_REFERENCE_PERIOD,
+ volume=VOLUME[0],
+ short=True,
+ )
+
+ def test_an_instrument_writing_nothing_sounds_on_no_channel(self) -> None:
+ instrument = Instrument(name="empty")
+
+ assert all(not instrument.instructions(channel_name) for channel_name in ChannelName.items())
+
+ def test_an_edit_reaches_the_frames(self) -> None:
+ instrument = _instrument()
+ before = instrument.instructions(ChannelName.PULSE1)
+
+ instrument.envelopes = instrument.envelopes.with_envelope(FeatureKey.ARPEGGIO, Envelope(items=(7,)))
+ instrument.invalidate()
+
+ after = instrument.instructions(ChannelName.PULSE1)
+ assert after != before
+ assert isinstance(after[0], PulseInstruction)
+ assert after[0].pitch == RESTING_REFERENCE_PITCH + 7
+
+
+class TestHeldDimensions:
+ def test_an_empty_envelope_is_left_to_the_channel(self) -> None:
+ instrument = Instrument(name="lead", envelopes=InstrumentEnvelopes(arpeggio=Envelope(items=ARPEGGIO)))
+
+ held = instrument.held_features(ChannelName.PULSE1)
+
+ assert FeatureKey.VOLUME in held
+ assert FeatureKey.DUTY_CYCLE in held
+ assert FeatureKey.ARPEGGIO not in held
+
+ def test_a_channel_is_told_of_the_dimensions_it_offers_alone(self) -> None:
+ instrument = Instrument(name="lead", envelopes=InstrumentEnvelopes(arpeggio=Envelope(items=ARPEGGIO)))
+
+ assert FeatureKey.DUTY_CYCLE not in instrument.held_features(ChannelName.TRIANGLE)
+
+
+class TestEnvelopeBounds:
+ def test_a_volume_past_the_range_is_refused(self) -> None:
+ with pytest.raises(ValidationError):
+ InstrumentEnvelopes(volume=Envelope(items=(MAX_VOLUME + 1,)))
+
+ def test_a_dimension_an_instrument_writes_none_of_is_refused(self) -> None:
+ with pytest.raises(KeyError):
+ InstrumentEnvelopes().with_envelope(FeatureKey.INITIAL_PITCH, Envelope(items=(1,)))
+
+ def test_the_frame_count_is_the_longest_dimension(self) -> None:
+ envelopes = InstrumentEnvelopes(volume=Envelope(items=VOLUME), duty_cycle=Envelope(items=DUTY_CYCLE))
+
+ assert envelopes.frame_count == len(VOLUME)
diff --git a/tests/unit/sampletones_core/project/voices/test_record.py b/tests/unit/sampletones_core/project/voices/test_record.py
new file mode 100644
index 000000000..1e7af612c
--- /dev/null
+++ b/tests/unit/sampletones_core/project/voices/test_record.py
@@ -0,0 +1,48 @@
+import pytest
+from pydantic import TypeAdapter, ValidationError
+
+from sampletones_core.project.voices.instrument import Instrument
+from sampletones_core.project.voices.record import SampleRecord, VoiceRecord
+
+SAMPLE_KIND = "sample"
+INSTRUMENT_KIND = "instrument"
+
+_RECORDS = TypeAdapter(VoiceRecord)
+
+
+def _sample_record() -> SampleRecord:
+ return SampleRecord(id="voice-id", name="Bass", reconstruction_id="reconstruction-id")
+
+
+class TestTheKindADocumentNamesAVoiceBy:
+ """A ``.stn`` tells its two kinds of voice apart by ``kind``, so the word is the format.
+
+ A document written by any release names a sample ``"sample"`` and a hand-written voice
+ ``"instrument"``; reading one back turns on those exact words, which is why they are stated
+ here rather than read from the models.
+ """
+
+ def test_a_sample_is_written_under_its_own_word(self) -> None:
+ assert _sample_record().model_dump()["kind"] == SAMPLE_KIND
+
+ def test_an_instrument_is_written_under_its_own_word(self) -> None:
+ assert Instrument(name="Pad").model_dump()["kind"] == INSTRUMENT_KIND
+
+ def test_a_sample_record_is_read_back_as_one(self) -> None:
+ restored = _RECORDS.validate_python(_sample_record().model_dump())
+
+ assert isinstance(restored, SampleRecord)
+ assert restored.reconstruction_id == "reconstruction-id"
+
+ def test_an_instrument_record_is_read_back_as_one(self) -> None:
+ restored = _RECORDS.validate_python(Instrument(name="Pad").model_dump())
+
+ assert isinstance(restored, Instrument)
+ assert restored.name == "Pad"
+
+ def test_a_word_neither_kind_answers_to_is_refused(self) -> None:
+ record = _sample_record().model_dump()
+ record["kind"] = "recording"
+
+ with pytest.raises(ValidationError):
+ _RECORDS.validate_python(record)
diff --git a/tests/unit/sampletones_core/project/instruments/test_sample.py b/tests/unit/sampletones_core/project/voices/test_sample.py
similarity index 65%
rename from tests/unit/sampletones_core/project/instruments/test_sample.py
rename to tests/unit/sampletones_core/project/voices/test_sample.py
index 3d5cab3bb..d967f6025 100644
--- a/tests/unit/sampletones_core/project/instruments/test_sample.py
+++ b/tests/unit/sampletones_core/project/voices/test_sample.py
@@ -1,6 +1,6 @@
from unittest.mock import Mock
-from sampletones_core.project.instruments.sample import Sample
+from sampletones_core.project.voices.sample import Sample
class TestSampleClone:
@@ -8,11 +8,10 @@ def test_clone_gets_a_fresh_id(self) -> None:
sample = Sample(name="lead", reconstruction=Mock())
assert sample.clone().id != sample.id
- def test_clone_carries_name_and_loop(self) -> None:
- sample = Sample(name="lead", reconstruction=Mock(), loop=True)
- clone = sample.clone()
- assert clone.name == "lead"
- assert clone.loop is True
+ def test_clone_carries_the_name(self) -> None:
+ sample = Sample(name="lead", reconstruction=Mock())
+
+ assert sample.clone().name == "lead"
def test_clone_deep_copies_the_reconstruction(self) -> None:
reconstruction = Mock()
diff --git a/tests/unit/sampletones_core/reconstructions/converter/paths/test_fields.py b/tests/unit/sampletones_core/reconstructions/converter/paths/test_fields.py
index ce127850c..55af0c517 100644
--- a/tests/unit/sampletones_core/reconstructions/converter/paths/test_fields.py
+++ b/tests/unit/sampletones_core/reconstructions/converter/paths/test_fields.py
@@ -8,7 +8,7 @@
format_spectrum_method,
format_transformation_gamma,
)
-from sampletones_core.constants.enums import GeneratorName, abbreviate_generator_names
+from sampletones_core.constants.enums import ChannelName, abbreviate_channel_names
from sampletones_core.reconstructions.converter.paths import ConfigDirectoryFields
HASH = "6edf7c948606917a78b45d153c7ca7e0"
@@ -35,7 +35,7 @@ def test_same_config_produces_same_name(self, config: Config) -> None:
def test_different_generator_sets_produce_different_names(self, config: Config) -> None:
single_generator_config = config.model_copy(
- update={"generation": config.generation.model_copy(update={"generators": [GeneratorName.PULSE1]})}
+ update={"generation": config.generation.model_copy(update={"channels": [ChannelName.PULSE1]})}
)
assert ConfigDirectoryFields.generate_config_directory_name(
@@ -58,7 +58,7 @@ def test_parses_components(self, config: Config) -> None:
assert fields.nf == config.library.nes_frequency
assert fields.sm == config.library.spectrum_method
assert fields.tg == config.library.transformation_gamma
- assert fields.generators == tuple(config.generation.generators)
+ assert fields.channels == tuple(config.generation.channels)
def test_directory_name_embeds_field_keys(self, config: Config) -> None:
name = ConfigDirectoryFields.generate_config_directory_name(config)
@@ -90,5 +90,5 @@ def test_display_name_combines_formatted_parts(self, config: Config) -> None:
assert format_nes_frequency(config.library.nes_frequency) in display
assert format_spectrum_method(config.library.spectrum_method) in display
assert format_transformation_gamma(config.library.transformation_gamma) in display
- assert abbreviate_generator_names(list(config.generation.generators)) in display
+ assert abbreviate_channel_names(list(config.generation.channels)) in display
assert DISPLAY_SEPARATOR in display
diff --git a/tests/unit/sampletones_core/reconstructions/converter/paths/test_utils.py b/tests/unit/sampletones_core/reconstructions/converter/paths/test_utils.py
index 6955b77ce..04f32ab78 100644
--- a/tests/unit/sampletones_core/reconstructions/converter/paths/test_utils.py
+++ b/tests/unit/sampletones_core/reconstructions/converter/paths/test_utils.py
@@ -9,6 +9,8 @@
get_audio_files,
get_output_path,
get_relative_path,
+ group_output_path,
+ top_level_audio_files,
)
from sampletones_shared.paths.extensions import EXT_FILE_RECONSTRUCTION
@@ -115,3 +117,25 @@ def test_excludes_files_with_existing_output(self, tmp_path: Path) -> None:
output_file.touch()
result = filter_files([audio_file], tmp_path, output_directory)
assert result == []
+
+
+class TestTopLevelAudioFiles:
+ """Gathering the sources of one reconstruction stays with the folder that was pointed at."""
+
+ def test_reports_the_audio_files_the_folder_itself_holds(self, tmp_path: Path) -> None:
+ (tmp_path / "b.wav").touch()
+ (tmp_path / "a.wav").touch()
+ (tmp_path / "notes.txt").write_text("not audio")
+
+ assert [path.name for path in top_level_audio_files(tmp_path)] == ["a.wav", "b.wav"]
+
+ def test_a_nested_recording_stays_where_it_is(self, tmp_path: Path) -> None:
+ nested = tmp_path / "nested"
+ nested.mkdir()
+ (nested / "deep.wav").touch()
+ (tmp_path / "a.wav").touch()
+
+ assert [path.name for path in top_level_audio_files(tmp_path)] == ["a.wav"]
+
+ def test_a_folder_of_nothing_reports_nothing(self, tmp_path: Path) -> None:
+ assert top_level_audio_files(tmp_path) == []
diff --git a/tests/unit/sampletones_core/reconstructions/converter/plan/__init__.py b/tests/unit/sampletones_core/reconstructions/converter/plan/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/tests/unit/sampletones_core/reconstructions/converter/plan/test_plans.py b/tests/unit/sampletones_core/reconstructions/converter/plan/test_plans.py
new file mode 100644
index 000000000..57fe42d78
--- /dev/null
+++ b/tests/unit/sampletones_core/reconstructions/converter/plan/test_plans.py
@@ -0,0 +1,201 @@
+from pathlib import Path
+from typing import List
+
+import pytest
+
+from sampletones_core.configs import Config
+from sampletones_core.constants.enums import ChannelName
+from sampletones_core.reconstructions.converter.paths.utils import get_output_path, group_output_path
+from sampletones_core.reconstructions.converter.plan.directory import DirectoryConversion
+from sampletones_core.reconstructions.converter.plan.group import GroupConversion
+from sampletones_core.reconstructions.reconstructor.stems.configs.config import StemsConfig
+from sampletones_shared.exceptions import NoFilesToProcessError
+
+
+@pytest.fixture(scope="module")
+def config() -> Config:
+ return Config()
+
+
+@pytest.fixture(scope="module")
+def stems(config: Config) -> StemsConfig:
+ return StemsConfig.single_entry(list(config.generation.channels))
+
+
+def _write_audio_files(directory: Path, names: List[str]) -> List[Path]:
+ paths = []
+ for name in names:
+ path = directory / name
+ path.parent.mkdir(parents=True, exist_ok=True)
+ path.touch()
+ paths.append(path)
+
+ return paths
+
+
+class TestGroupConversion:
+ def test_one_source_makes_one_job_over_that_source(
+ self,
+ config: Config,
+ stems: StemsConfig,
+ tmp_path: Path,
+ ) -> None:
+ source = _write_audio_files(tmp_path, ["song.wav"])[0]
+
+ jobs = GroupConversion(sources=(source,), stems=stems).jobs(config)
+
+ assert len(jobs) == 1
+ assert jobs[0].sources == (source,)
+ assert jobs[0].stems == stems
+ assert jobs[0].output_path == get_output_path(config, source)
+
+ def test_several_sources_make_one_job_over_all_of_them(
+ self,
+ config: Config,
+ stems: StemsConfig,
+ tmp_path: Path,
+ ) -> None:
+ """Several recordings mix into one reconstruction, so they amount to one job."""
+ sources = tuple(_write_audio_files(tmp_path, ["bass.wav", "drums.wav", "lead.wav"]))
+
+ jobs = GroupConversion(sources=sources, stems=stems).jobs(config)
+
+ assert len(jobs) == 1
+ assert jobs[0].sources == sources
+ assert jobs[0].output_path == group_output_path(config, sources)
+
+ def test_the_setup_travels_with_the_job(
+ self,
+ config: Config,
+ tmp_path: Path,
+ ) -> None:
+ sources = tuple(_write_audio_files(tmp_path, ["a.wav", "b.wav"]))
+ targeted = StemsConfig.single_entry([ChannelName.PULSE1], channel_cap=1)
+
+ jobs = GroupConversion(sources=sources, stems=targeted).jobs(config)
+
+ assert jobs[0].stems == targeted
+
+
+class TestDirectoryConversion:
+ def test_every_audio_file_becomes_its_own_single_source_job(
+ self,
+ config: Config,
+ stems: StemsConfig,
+ tmp_path: Path,
+ ) -> None:
+ _write_audio_files(tmp_path, ["a.wav", "nested/b.wav"])
+
+ jobs = DirectoryConversion(directory=tmp_path, stems=stems).jobs(config)
+
+ assert len(jobs) == 2
+ assert all(len(job.sources) == 1 for job in jobs)
+ assert {job.sources[0].name for job in jobs} == {"a.wav", "b.wav"}
+
+ def test_the_output_tree_mirrors_the_input_tree(
+ self,
+ config: Config,
+ stems: StemsConfig,
+ tmp_path: Path,
+ ) -> None:
+ _write_audio_files(tmp_path, ["nested/deeper/b.wav"])
+ output_path = get_output_path(config, tmp_path)
+
+ jobs = DirectoryConversion(directory=tmp_path, stems=stems).jobs(config)
+
+ assert jobs[0].output_path == output_path / "nested" / "deeper" / "b.stn"
+
+ def test_every_job_carries_the_same_setup(
+ self,
+ config: Config,
+ stems: StemsConfig,
+ tmp_path: Path,
+ ) -> None:
+ _write_audio_files(tmp_path, ["a.wav", "b.wav"])
+
+ jobs = DirectoryConversion(directory=tmp_path, stems=stems).jobs(config)
+
+ assert [job.stems for job in jobs] == [stems, stems]
+
+ def test_a_directory_holding_nothing_to_convert_raises(
+ self,
+ config: Config,
+ stems: StemsConfig,
+ tmp_path: Path,
+ ) -> None:
+ with pytest.raises(NoFilesToProcessError):
+ DirectoryConversion(directory=tmp_path, stems=stems).jobs(config)
+
+
+def _config_writing_under(reconstructions_directory: Path) -> Config:
+ """A configuration whose reconstructions are written under ``reconstructions_directory``."""
+ config = Config()
+ general = config.general.model_copy(update={"reconstructions_directory": str(reconstructions_directory)})
+ return config.model_copy(update={"general": general})
+
+
+class TestExistingTargets:
+ """What a plan would write over, which is what a caller settles before starting a run."""
+
+ def test_a_group_conversion_names_the_target_already_standing(
+ self,
+ stems: StemsConfig,
+ tmp_path: Path,
+ ) -> None:
+ config = _config_writing_under(tmp_path / "out")
+ source = _write_audio_files(tmp_path, ["song.wav"])[0]
+ plan = GroupConversion(sources=(source,), stems=stems)
+ target = plan.jobs(config)[0].output_path
+ target.parent.mkdir(parents=True)
+ target.touch()
+
+ assert plan.existing_targets(config) == (target,)
+
+ def test_a_target_still_to_be_written_leaves_the_answer_empty(
+ self,
+ stems: StemsConfig,
+ tmp_path: Path,
+ ) -> None:
+ config = _config_writing_under(tmp_path / "out")
+ source = _write_audio_files(tmp_path, ["song.wav"])[0]
+
+ assert GroupConversion(sources=(source,), stems=stems).existing_targets(config) == ()
+
+ def test_a_directory_sitting_at_the_target_path_leaves_the_answer_empty(
+ self,
+ stems: StemsConfig,
+ tmp_path: Path,
+ ) -> None:
+ """A target is a file, so a directory of that name is a different matter the run reports itself."""
+ config = _config_writing_under(tmp_path / "out")
+ source = _write_audio_files(tmp_path, ["song.wav"])[0]
+ plan = GroupConversion(sources=(source,), stems=stems)
+ plan.jobs(config)[0].output_path.mkdir(parents=True)
+
+ assert plan.existing_targets(config) == ()
+
+ def test_a_directory_conversion_settles_the_question_itself(
+ self,
+ stems: StemsConfig,
+ tmp_path: Path,
+ ) -> None:
+ """The scan converts what is still to be written, so a standing output puts nothing to the reader."""
+ config = _config_writing_under(tmp_path / "out")
+ _write_audio_files(tmp_path, ["a.wav"])
+ plan = DirectoryConversion(directory=tmp_path, stems=stems)
+ target = plan.jobs(config)[0].output_path
+ target.parent.mkdir(parents=True)
+ target.touch()
+
+ assert plan.existing_targets(config) == ()
+
+
+class TestGroupOutputPath:
+ def test_one_source_names_the_file_after_itself(self, config: Config, tmp_path: Path) -> None:
+ source = tmp_path / "song.wav"
+ source.touch()
+ assert group_output_path(config, (source,)) == get_output_path(config, source)
+
+ def test_sources_sharing_a_directory_name_the_file_after_it(self, config: Config, tmp_path: Path) -> None:
+ sources = tuple(_write_audio_files(tmp_path / "session", ["a.wav", "b.wav"]))
+ assert group_output_path(config, sources).stem == "session"
diff --git a/tests/unit/sampletones_core/reconstructions/converter/test_conversion.py b/tests/unit/sampletones_core/reconstructions/converter/test_conversion.py
index 741c36dbc..cd1045770 100644
--- a/tests/unit/sampletones_core/reconstructions/converter/test_conversion.py
+++ b/tests/unit/sampletones_core/reconstructions/converter/test_conversion.py
@@ -3,9 +3,13 @@
import pytest
-from sampletones_core.reconstructions.converter.conversion import reconstruct_file
+from sampletones_core.configs import Config
+from sampletones_core.reconstructions.converter.conversion import reconstruct_job
+from sampletones_core.reconstructions.converter.job import ConversionJob
from sampletones_core.reconstructions.reconstructor.reconstructor import Reconstructor
+from sampletones_core.reconstructions.reconstructor.stems.configs.config import StemsConfig
from sampletones_shared.exceptions import UnsupportedAudioFormatError
+from sampletones_shared.utils.progress import silent_reporter
@pytest.fixture
@@ -13,36 +17,57 @@ def mock_reconstructor() -> MagicMock:
return MagicMock(spec=Reconstructor)
-class TestReconstructFile:
+def _job(tmp_path: Path, output_path: Path) -> ConversionJob:
+ return ConversionJob(
+ sources=(tmp_path / "song.wav",),
+ stems=StemsConfig.single_entry(list(Config().generation.channels)),
+ output_path=output_path,
+ )
+
+
+class TestReconstructJob:
def test_creates_parent_directory_when_not_exist(
self,
mock_reconstructor: MagicMock,
tmp_path: Path,
) -> None:
output_path = tmp_path / "nested" / "dir" / "song.stn"
- reconstruct_file((mock_reconstructor, tmp_path / "song.wav", output_path))
+ reconstruct_job((mock_reconstructor, _job(tmp_path, output_path), silent_reporter))
assert output_path.parent.exists()
+ def test_builds_the_reconstruction_from_the_jobs_sources_and_setup(
+ self,
+ mock_reconstructor: MagicMock,
+ tmp_path: Path,
+ ) -> None:
+ job = _job(tmp_path, tmp_path / "song.stn")
+ reconstruct_job((mock_reconstructor, job, silent_reporter))
+ mock_reconstructor.reconstruct.assert_called_once_with(
+ job.sources,
+ job.stems,
+ report=silent_reporter,
+ )
+
def test_saves_reconstruction_to_output_path(
self,
mock_reconstructor: MagicMock,
tmp_path: Path,
) -> None:
mock_reconstruction = MagicMock()
- mock_reconstructor.return_value = mock_reconstruction
+ mock_reconstructor.reconstruct.return_value = mock_reconstruction
output_path = tmp_path / "song.stn"
- reconstruct_file((mock_reconstructor, tmp_path / "song.wav", output_path))
+ reconstruct_job((mock_reconstructor, _job(tmp_path, output_path), silent_reporter))
mock_reconstruction.save.assert_called_once_with(output_path)
- def test_does_not_save_when_reconstructor_returns_none(
+ def test_reports_the_output_path_when_the_reconstruction_is_empty(
self,
mock_reconstructor: MagicMock,
tmp_path: Path,
) -> None:
- mock_reconstructor.return_value = None
+ mock_reconstructor.reconstruct.return_value = None
output_path = tmp_path / "song.stn"
- result = reconstruct_file((mock_reconstructor, tmp_path / "song.wav", output_path))
- assert result == output_path
+ job = _job(tmp_path, output_path)
+ assert reconstruct_job((mock_reconstructor, job, silent_reporter)) == output_path
def test_always_returns_output_path(
self,
@@ -50,26 +75,24 @@ def test_always_returns_output_path(
tmp_path: Path,
) -> None:
output_path = tmp_path / "song.stn"
- result = reconstruct_file((mock_reconstructor, tmp_path / "song.wav", output_path))
- assert result == output_path
+ job = _job(tmp_path, output_path)
+ assert reconstruct_job((mock_reconstructor, job, silent_reporter)) == output_path
def test_unsupported_audio_format_error_is_swallowed(
self,
mock_reconstructor: MagicMock,
tmp_path: Path,
) -> None:
- mock_reconstructor.side_effect = UnsupportedAudioFormatError("bad format")
+ mock_reconstructor.reconstruct.side_effect = UnsupportedAudioFormatError("bad format")
output_path = tmp_path / "song.stn"
- result = reconstruct_file((mock_reconstructor, tmp_path / "song.wav", output_path))
- assert result == output_path
+ job = _job(tmp_path, output_path)
+ assert reconstruct_job((mock_reconstructor, job, silent_reporter)) == output_path
def test_keyboard_interrupt_is_reraised(
self,
mock_reconstructor: MagicMock,
tmp_path: Path,
) -> None:
- mock_reconstructor.side_effect = KeyboardInterrupt
+ mock_reconstructor.reconstruct.side_effect = KeyboardInterrupt
with pytest.raises(KeyboardInterrupt):
- reconstruct_file(
- (mock_reconstructor, tmp_path / "song.wav", tmp_path / "song.stn"),
- )
+ reconstruct_job((mock_reconstructor, _job(tmp_path, tmp_path / "song.stn"), silent_reporter))
diff --git a/tests/unit/sampletones_core/reconstructions/converter/test_converter.py b/tests/unit/sampletones_core/reconstructions/converter/test_converter.py
index 2819e3ccb..15fc2c2cd 100644
--- a/tests/unit/sampletones_core/reconstructions/converter/test_converter.py
+++ b/tests/unit/sampletones_core/reconstructions/converter/test_converter.py
@@ -5,9 +5,12 @@
from sampletones_core.configs import Config
from sampletones_core.reconstructions.converter import (
+ DirectoryConversion,
+ GroupConversion,
ReconstructionConverter,
- reconstruct_file,
+ reconstruct_job,
)
+from sampletones_core.reconstructions.reconstructor.stems.configs.config import StemsConfig
from sampletones_shared.exceptions import NoFilesToProcessError
_RECONSTRUCTOR_PATCH = "sampletones_core.reconstructions.converter.converter.Reconstructor"
@@ -18,155 +21,173 @@ def config() -> Config:
return Config()
-class TestReconstructionConverterInit:
- def test_stores_config_as_copy(self, config: Config, tmp_path: Path) -> None:
- converter = ReconstructionConverter(
- config,
- tmp_path / "song.wav",
- is_file=True,
- )
- assert converter.config is not config
+@pytest.fixture(scope="module")
+def stems(config: Config) -> StemsConfig:
+ return StemsConfig.single_entry(list(config.generation.channels))
- def test_stores_input_path(self, config: Config, tmp_path: Path) -> None:
- input_path = tmp_path / "song.wav"
- converter = ReconstructionConverter(config, input_path, is_file=True)
- assert converter.input_path == input_path
- def test_is_file_flag_stored(self, config: Config, tmp_path: Path) -> None:
- converter = ReconstructionConverter(
- config,
- tmp_path / "song.wav",
- is_file=True,
- )
- assert converter.is_file is True
+def _group(path: Path, stems: StemsConfig) -> GroupConversion:
+ return GroupConversion(sources=(path,), stems=stems)
- def test_tracking_variables_initialized_to_zero(
- self,
- config: Config,
- tmp_path: Path,
- ) -> None:
- converter = ReconstructionConverter(
- config,
- tmp_path / "song.wav",
- is_file=True,
- )
+
+class TestReconstructionConverterInit:
+ def test_stores_config_as_copy(self, config: Config, stems: StemsConfig, tmp_path: Path) -> None:
+ converter = ReconstructionConverter(config, _group(tmp_path / "song.wav", stems))
+ assert converter.config is not config
+
+ def test_stores_the_plan_it_runs(self, config: Config, stems: StemsConfig, tmp_path: Path) -> None:
+ plan = _group(tmp_path / "song.wav", stems)
+ converter = ReconstructionConverter(config, plan)
+ assert converter.plan is plan
class TestReconstructionConverterStart:
def test_already_running_does_not_spawn_thread(
self,
config: Config,
+ stems: StemsConfig,
tmp_path: Path,
) -> None:
- converter = ReconstructionConverter(
- config,
- tmp_path / "song.wav",
- is_file=True,
- )
+ converter = ReconstructionConverter(config, _group(tmp_path / "song.wav", stems))
converter.running = True
converter.start()
assert converter.monitor_thread is None
class TestReconstructionConverterCreateTasks:
- def test_file_input_returns_single_task(
+ def test_a_group_plan_returns_a_single_task(
self,
config: Config,
+ stems: StemsConfig,
tmp_path: Path,
) -> None:
audio_file = tmp_path / "song.wav"
audio_file.touch()
- converter = ReconstructionConverter(config, audio_file, is_file=True)
+ converter = ReconstructionConverter(config, _group(audio_file, stems))
with patch(_RECONSTRUCTOR_PATCH):
- result = converter._create_tasks()
- assert len(result) == 1
+ tasks = converter._create_tasks()
+ assert len(tasks) == 1
- def test_file_task_uses_input_path_as_source(
+ def test_each_task_carries_its_job(
self,
config: Config,
+ stems: StemsConfig,
tmp_path: Path,
) -> None:
audio_file = tmp_path / "song.wav"
audio_file.touch()
- converter = ReconstructionConverter(config, audio_file, is_file=True)
+ converter = ReconstructionConverter(config, _group(audio_file, stems))
with patch(_RECONSTRUCTOR_PATCH):
- result = converter._create_tasks()
- assert result[0][1] == audio_file
+ tasks = converter._create_tasks()
+ assert tasks[0][1] is converter.jobs[0]
+ assert converter.jobs[0].sources == (audio_file,)
- def test_directory_input_returns_one_task_per_audio_file(
+ def test_a_directory_plan_returns_one_task_per_audio_file(
self,
config: Config,
+ stems: StemsConfig,
tmp_path: Path,
) -> None:
(tmp_path / "a.wav").touch()
(tmp_path / "b.wav").touch()
- converter = ReconstructionConverter(config, tmp_path, is_file=False)
+ converter = ReconstructionConverter(config, DirectoryConversion(directory=tmp_path, stems=stems))
with patch(_RECONSTRUCTOR_PATCH):
- result = converter._create_tasks()
- assert len(result) == 2
+ tasks = converter._create_tasks()
+ assert len(tasks) == 2
- def test_directory_with_no_audio_files_raises_no_files_to_process_error(
+ def test_a_directory_with_no_audio_files_raises_no_files_to_process_error(
self,
config: Config,
+ stems: StemsConfig,
tmp_path: Path,
) -> None:
- converter = ReconstructionConverter(config, tmp_path, is_file=False)
+ converter = ReconstructionConverter(config, DirectoryConversion(directory=tmp_path, stems=stems))
with patch(_RECONSTRUCTOR_PATCH):
with pytest.raises(NoFilesToProcessError):
converter._create_tasks()
class TestReconstructionConverterGetTaskFunction:
- def test_returns_reconstruct_file_function(
+ def test_returns_the_job_conversion_function(
self,
config: Config,
+ stems: StemsConfig,
tmp_path: Path,
) -> None:
- converter = ReconstructionConverter(
- config,
- tmp_path / "song.wav",
- is_file=True,
- )
- assert converter._get_task_function() is reconstruct_file
+ converter = ReconstructionConverter(config, _group(tmp_path / "song.wav", stems))
+ assert converter._get_task_function() is reconstruct_job
class TestReconstructionConverterProcessResults:
- def test_file_mode_returns_first_result(
+ def test_reports_the_reconstructions_that_were_written(
self,
config: Config,
+ stems: StemsConfig,
tmp_path: Path,
) -> None:
- path_a = tmp_path / "a.stn"
- path_b = tmp_path / "b.stn"
- converter = ReconstructionConverter(
- config,
- tmp_path / "song.wav",
- is_file=True,
- )
- assert converter._process_results([path_a, path_b]) == path_a
-
- def test_directory_mode_returns_input_path(
+ written = tmp_path / "a.stn"
+ written.touch()
+ converter = ReconstructionConverter(config, _group(tmp_path / "song.wav", stems))
+ assert converter._process_results([written]) == (written,)
+
+ def test_leaves_out_a_job_that_wrote_nothing(
self,
config: Config,
+ stems: StemsConfig,
tmp_path: Path,
) -> None:
- converter = ReconstructionConverter(config, tmp_path, is_file=False)
- assert converter._process_results([tmp_path / "a.stn"]) == tmp_path
+ """A source the loader has no reader for names its output and writes none, so the run
+ reports only what a reader can open."""
+ written = tmp_path / "a.stn"
+ written.touch()
+ converter = ReconstructionConverter(config, _group(tmp_path / "song.wav", stems))
+ assert converter._process_results([written, tmp_path / "missing.stn"]) == (written,)
class TestReconstructionConverterNotifyProgress:
- def test_current_file_set_after_first_completion(
+ """A run names the recording it is working on, which is what a reader watching it wants."""
+
+ @staticmethod
+ def _converter_over_two_recordings(
+ config: Config,
+ stems: StemsConfig,
+ tmp_path: Path,
+ ) -> ReconstructionConverter:
+ (tmp_path / "a.wav").touch()
+ (tmp_path / "b.wav").touch()
+ converter = ReconstructionConverter(config, DirectoryConversion(directory=tmp_path, stems=stems))
+ with patch(_RECONSTRUCTOR_PATCH):
+ converter._create_tasks()
+
+ converter.total_tasks = len(converter.jobs)
+ return converter
+
+ def test_the_run_names_the_job_it_is_working_on(
+ self,
+ config: Config,
+ stems: StemsConfig,
+ tmp_path: Path,
+ ) -> None:
+ converter = self._converter_over_two_recordings(config, stems, tmp_path)
+ try:
+ converter.completed_tasks = 1
+ converter._notify_progress()
+
+ assert converter.current_item == str(converter.jobs[1].sources[0])
+ finally:
+ converter.shutdown()
+
+ def test_a_finished_run_names_the_job_it_ended_on(
self,
config: Config,
+ stems: StemsConfig,
tmp_path: Path,
) -> None:
- path_a = tmp_path / "a.wav"
- path_b = tmp_path / "b.wav"
- converter = ReconstructionConverter(config, tmp_path, is_file=False)
- converter.audio_files = [path_a, path_b]
- converter.total_tasks = 2
- converter.completed_tasks = 1
- converter._notify_progress()
- assert converter.current_file == str(path_a)
- converter._notify_progress()
- assert converter.current_file == str(path_a)
+ converter = self._converter_over_two_recordings(config, stems, tmp_path)
+ try:
+ converter.completed_tasks = len(converter.jobs)
+ converter._notify_progress()
+
+ assert converter.current_item == str(converter.jobs[-1].sources[0])
+ finally:
+ converter.shutdown()
diff --git a/tests/unit/sampletones_core/reconstructions/converter/test_progress.py b/tests/unit/sampletones_core/reconstructions/converter/test_progress.py
new file mode 100644
index 000000000..7ea81a540
--- /dev/null
+++ b/tests/unit/sampletones_core/reconstructions/converter/test_progress.py
@@ -0,0 +1,125 @@
+from typing import Final, List
+
+import pytest
+
+from sampletones_core.parallelization.task import TaskStep
+from sampletones_core.reconstructions.converter.progress import JobReporter
+from sampletones_core.reconstructions.progress import ReconstructionProgress, announce
+from sampletones_core.reconstructions.stage import ReconstructionStage
+from sampletones_shared.exceptions import OperationCanceled
+from sampletones_shared.utils.progress import PROGRESS_STEPS
+from tests.suite.base import BaseTestSuite
+
+FRAMES: Final[int] = PROGRESS_STEPS * 10
+WHOLE_STAGE: Final[int] = 1
+STAGE_BEGUN: Final[int] = 0
+
+
+class RecordingLine:
+ """A line that keeps the steps a job filed, and withdraws the run after a chosen number.
+
+ Stands in for the line a job reports on so a test reads what the job said without a worker
+ process on the other end of it.
+ """
+
+ def __init__(self, withdraw_after: int = 0) -> None:
+ self.steps: List[TaskStep] = []
+ self._withdraw_after = withdraw_after
+
+ def __call__(self, step: TaskStep) -> bool:
+ self.steps.append(step)
+ return not self._withdraw_after or len(self.steps) < self._withdraw_after
+
+
+class TestWhatAJobFiles(BaseTestSuite):
+ """A job's step names the stage it is in and how far the whole job has come."""
+
+ def test_a_step_carries_the_stage_and_its_counts(self) -> None:
+ line = RecordingLine()
+ reporter = JobReporter(line)
+
+ reporter(ReconstructionProgress(stage=ReconstructionStage.MATCHING, completed=0, total=FRAMES))
+
+ assert line.steps == [
+ TaskStep(
+ stage=ReconstructionStage.MATCHING.value,
+ completed=0,
+ total=FRAMES,
+ fraction=ReconstructionStage.MATCHING.offset,
+ )
+ ]
+
+ def test_the_fraction_weighs_the_stage_into_the_whole_job(self) -> None:
+ line = RecordingLine()
+ reporter = JobReporter(line)
+
+ reporter(ReconstructionProgress(stage=ReconstructionStage.MATCHING, completed=FRAMES, total=FRAMES))
+
+ filed = line.steps[-1]
+ assert filed.fraction == pytest.approx(ReconstructionStage.MATCHING.offset + ReconstructionStage.MATCHING.share)
+
+ def test_every_stage_is_heard_from_as_it_begins(self) -> None:
+ line = RecordingLine()
+ reporter = JobReporter(line)
+
+ for stage in ReconstructionStage:
+ reporter(ReconstructionProgress(stage=stage, completed=STAGE_BEGUN, total=WHOLE_STAGE))
+
+ assert [step.stage for step in line.steps] == [stage.value for stage in ReconstructionStage]
+
+
+class TestHowOftenAJobIsHeardFrom(BaseTestSuite):
+ """A frame-by-frame account is carried at a rate a line and a bar can keep up with."""
+
+ def test_a_long_stage_files_about_as_many_steps_as_a_bar_has(self) -> None:
+ line = RecordingLine()
+ reporter = JobReporter(line)
+
+ for frame in range(FRAMES + 1):
+ reporter(ReconstructionProgress(stage=ReconstructionStage.MATCHING, completed=frame, total=FRAMES))
+
+ assert len(line.steps) == pytest.approx(PROGRESS_STEPS + 1, abs=1)
+
+ def test_a_stage_is_heard_from_where_it_lands(self) -> None:
+ line = RecordingLine()
+ reporter = JobReporter(line)
+
+ for frame in range(FRAMES + 1):
+ reporter(ReconstructionProgress(stage=ReconstructionStage.MATCHING, completed=frame, total=FRAMES))
+
+ assert line.steps[-1].completed == FRAMES
+
+ def test_each_stage_is_spaced_by_its_own_length(self) -> None:
+ line = RecordingLine()
+ reporter = JobReporter(line)
+
+ reporter(ReconstructionProgress(stage=ReconstructionStage.MATCHING, completed=0, total=FRAMES))
+ reporter(ReconstructionProgress(stage=ReconstructionStage.DECODING, completed=0, total=WHOLE_STAGE))
+ reporter(ReconstructionProgress(stage=ReconstructionStage.DECODING, completed=WHOLE_STAGE, total=WHOLE_STAGE))
+
+ assert [step.stage for step in line.steps] == [
+ ReconstructionStage.MATCHING.value,
+ ReconstructionStage.DECODING.value,
+ ReconstructionStage.DECODING.value,
+ ]
+
+
+class TestAWithdrawalReachingTheJob(BaseTestSuite):
+ """A job told the run has been let go of unwinds where it stands."""
+
+ def test_a_withdrawn_job_unwinds_at_its_next_frame(self) -> None:
+ line = RecordingLine(withdraw_after=1)
+ reporter = JobReporter(line)
+
+ with pytest.raises(OperationCanceled):
+ for frame in range(FRAMES + 1):
+ announce(reporter, ReconstructionStage.MATCHING, frame, FRAMES)
+
+ def test_a_job_the_run_still_wants_carries_on(self) -> None:
+ line = RecordingLine()
+ reporter = JobReporter(line)
+
+ for frame in range(FRAMES + 1):
+ announce(reporter, ReconstructionStage.MATCHING, frame, FRAMES)
+
+ assert line.steps
diff --git a/tests/unit/sampletones_core/reconstructions/naming/test_naming.py b/tests/unit/sampletones_core/reconstructions/naming/test_naming.py
new file mode 100644
index 000000000..75781fbb8
--- /dev/null
+++ b/tests/unit/sampletones_core/reconstructions/naming/test_naming.py
@@ -0,0 +1,46 @@
+from pathlib import Path
+
+import pytest
+
+from sampletones_core.reconstructions.naming.derive import derive_name
+
+
+class TestDeriveName:
+ def test_single_source_names_after_its_stem(self) -> None:
+ assert derive_name((Path("/stems/kick.wav"),)) == "kick"
+
+ def test_sources_sharing_one_directory_name_after_it(self) -> None:
+ paths = (
+ Path("/stems/drums/kick.wav"),
+ Path("/stems/drums/snare.wav"),
+ )
+
+ assert derive_name(paths) == "drums"
+
+ def test_sources_sharing_a_filename_prefix_name_after_it(self) -> None:
+ paths = (
+ Path("/a/song_vocals.wav"),
+ Path("/b/song_drums.wav"),
+ )
+
+ assert derive_name(paths) == "song"
+
+ def test_sources_with_matching_filenames_name_after_them(self) -> None:
+ paths = (
+ Path("/a/kick.wav"),
+ Path("/b/kick.wav"),
+ )
+
+ assert derive_name(paths) == "kick"
+
+ def test_sources_sharing_no_directory_name_after_the_first_stem(self) -> None:
+ paths = (
+ Path("/a/kick.wav"),
+ Path("/b/snare.wav"),
+ )
+
+ assert derive_name(paths) == "kick"
+
+ def test_no_sources_raise(self) -> None:
+ with pytest.raises(ValueError, match="at least one"):
+ derive_name(())
diff --git a/tests/unit/sampletones_core/reconstructions/reconstruction/test_reconstruction.py b/tests/unit/sampletones_core/reconstructions/reconstruction/test_reconstruction.py
index fe7f269e6..2491b6f62 100644
--- a/tests/unit/sampletones_core/reconstructions/reconstruction/test_reconstruction.py
+++ b/tests/unit/sampletones_core/reconstructions/reconstruction/test_reconstruction.py
@@ -3,16 +3,25 @@
from typing import Callable, Final, List
from unittest.mock import patch
+import msgpack
import numpy as np
import pytest
+from pydantic import ValidationError
from sampletones_core.configs import Config
-from sampletones_core.constants.enums import FeatureKey, GeneratorName
+from sampletones_core.constants.enums import ChannelName, FeatureKey, HierarchyMode
from sampletones_core.data import Metadata
-from sampletones_core.features import resting_reference
+from sampletones_core.features import resting_held_features, resting_reference
from sampletones_core.instructions import PulseInstruction
from sampletones_core.reconstructions import Reconstruction
from sampletones_core.reconstructions.reconstruction.instructions import InstructionsItem
+from sampletones_core.reconstructions.reconstruction.stems.data import (
+ ChannelAssignment,
+ StemsData,
+)
+from sampletones_core.reconstructions.reconstructor.stems.configs.config import StemsConfig
+from sampletones_core.reconstructions.reconstructor.stems.configs.entry import StemEntry
+from sampletones_core.reconstructions.reconstructor.stems.configs.hierarchy import StemsHierarchy
from sampletones_shared.application import (
SAMPLETONES_RECONSTRUCTION_DATA_VERSION,
)
@@ -30,6 +39,7 @@
from tests.suite.base import BaseTestSuite
from tests.suite.case import BaseRegularTestCase
from tests.suite.errors import DIRECTORY_READ_ERRORS
+from tests.suite.stems import single_entry_stems_data
_RETUNED_FREQUENCY: Final[int] = DEFAULT_NES_FREQUENCY // 2
_FASTER_FREQUENCY: Final[int] = DEFAULT_NES_FREQUENCY * 2
@@ -48,11 +58,15 @@ def _pulse(pitch: int) -> PulseInstruction:
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},
+ approximations={ChannelName.PULSE1: np.zeros(_AUDIO_LENGTH, dtype=np.float32)},
+ instructions={ChannelName.PULSE1: instructions},
config=Config(),
coefficient=1.0,
- audio_filepath=Path("/dev/null"),
+ audio_filepath=(Path("/dev/null"),),
+ stems_data=single_entry_stems_data(
+ list(Config().generation.channels),
+ {ChannelName.PULSE1: instructions},
+ ),
)
@@ -64,12 +78,138 @@ def _saved_playing_channels_only(path: Path) -> Path:
"""
reconstruction = _reconstruction([_pulse(_BASE_PITCH)])
reconstruction.instructions_data = [
- item for item in reconstruction.instructions_data if item.generator_name == GeneratorName.PULSE1
+ item for item in reconstruction.instructions_data if item.channel_name == ChannelName.PULSE1
]
reconstruction.save(path)
return path
+class TestStemsDataRoundTrip:
+ def test_stems_data_survives_save_and_load(self, tmp_path: Path) -> None:
+ stems_config = StemsConfig(
+ entries=[StemEntry(id=0, channels=[ChannelName.PULSE1])],
+ hierarchy=StemsHierarchy(levels=[[0]], mode=HierarchyMode.STRICT),
+ channel_cap=1,
+ )
+ stems_data = StemsData(
+ config=stems_config,
+ assignments=[
+ ChannelAssignment(
+ channel_name=ChannelName.PULSE1,
+ stem_ids=[0, 0],
+ )
+ ],
+ )
+ reconstruction = Reconstruction.create(
+ approximation=np.zeros(_AUDIO_LENGTH, dtype=np.float32),
+ approximations={ChannelName.PULSE1: np.zeros(_AUDIO_LENGTH, dtype=np.float32)},
+ instructions={ChannelName.PULSE1: [_pulse(_BASE_PITCH), _pulse(_BASE_PITCH)]},
+ config=Config(),
+ coefficient=1.0,
+ audio_filepath=(Path("/dev/null"),),
+ stems_data=stems_data,
+ )
+ path = tmp_path / "stems.stn"
+ reconstruction.save(path)
+
+ loaded = Reconstruction.load(path)
+
+ assert loaded.stems_data == stems_data
+
+ def test_audio_filepath_tuple_survives_save_and_load(self, tmp_path: Path) -> None:
+ stem_paths = (
+ Path("/dev/null/stem_a.wav"),
+ Path("/dev/null/stem_b.wav"),
+ )
+ stems_data = StemsData(
+ config=StemsConfig(
+ entries=[
+ StemEntry(id=0, channels=[ChannelName.PULSE1]),
+ StemEntry(id=1, channels=[ChannelName.PULSE1]),
+ ],
+ hierarchy=StemsHierarchy(levels=[[0, 1]], mode=HierarchyMode.STRICT),
+ channel_cap=1,
+ ),
+ assignments=[],
+ )
+ reconstruction = Reconstruction.create(
+ approximation=np.zeros(_AUDIO_LENGTH, dtype=np.float32),
+ approximations={ChannelName.PULSE1: np.zeros(_AUDIO_LENGTH, dtype=np.float32)},
+ instructions={ChannelName.PULSE1: [_pulse(_BASE_PITCH)]},
+ config=Config(),
+ coefficient=1.0,
+ audio_filepath=stem_paths,
+ stems_data=stems_data,
+ )
+ path = tmp_path / "stems_paths.stn"
+ reconstruction.save(path)
+
+ loaded = Reconstruction.load(path)
+
+ assert loaded.audio_filepath == stem_paths
+
+ def test_paths_numbering_the_entries_is_enforced(self) -> None:
+ stems_data = StemsData.single_entry(
+ [ChannelName.PULSE1],
+ [ChannelAssignment(channel_name=ChannelName.PULSE1, stem_ids=[0])],
+ channel_cap=1,
+ )
+
+ with pytest.raises(ValidationError, match="one per stems entry"):
+ Reconstruction.create(
+ approximation=np.zeros(_AUDIO_LENGTH, dtype=np.float32),
+ approximations={ChannelName.PULSE1: np.zeros(_AUDIO_LENGTH, dtype=np.float32)},
+ instructions={ChannelName.PULSE1: [_pulse(_BASE_PITCH)]},
+ config=Config(),
+ coefficient=1.0,
+ audio_filepath=(Path("/dev/null/a.wav"), Path("/dev/null/b.wav")),
+ stems_data=stems_data,
+ )
+
+
+class TestSourcePaths:
+ def test_single_source_yields_one_path(self) -> None:
+ reconstruction = _reconstruction([_pulse(_BASE_PITCH)])
+
+ assert reconstruction.audio_filepath == (Path("/dev/null"),)
+
+ def test_stem_sources_yield_the_recorded_tuple(self) -> None:
+ stem_paths = (
+ Path("/dev/null/stem_a.wav"),
+ Path("/dev/null/stem_b.wav"),
+ )
+ stems_data = StemsData(
+ config=StemsConfig(
+ entries=[
+ StemEntry(id=0, channels=[ChannelName.PULSE1]),
+ StemEntry(id=1, channels=[ChannelName.PULSE1]),
+ ],
+ hierarchy=StemsHierarchy(levels=[[0, 1]], mode=HierarchyMode.STRICT),
+ channel_cap=1,
+ ),
+ assignments=[],
+ )
+ reconstruction = Reconstruction.create(
+ approximation=np.zeros(_AUDIO_LENGTH, dtype=np.float32),
+ approximations={ChannelName.PULSE1: np.zeros(_AUDIO_LENGTH, dtype=np.float32)},
+ instructions={ChannelName.PULSE1: [_pulse(_BASE_PITCH)]},
+ config=Config(),
+ coefficient=1.0,
+ audio_filepath=stem_paths,
+ stems_data=stems_data,
+ )
+
+ assert reconstruction.audio_filepath == stem_paths
+
+ def test_detaching_empties_the_source_paths(self) -> None:
+ reconstruction = _reconstruction([_pulse(_BASE_PITCH)])
+ assert reconstruction.audio_filepath == (Path("/dev/null"),)
+
+ reconstruction.detach_source()
+
+ assert reconstruction.audio_filepath == ()
+
+
class TestRoundTrip:
def test_save_load_round_trip(
self,
@@ -87,7 +227,7 @@ def test_save_load_round_trip(
assert loaded.audio_filepath == reconstruction.audio_filepath
assert_array_equal(loaded.approximation, reconstruction.approximation)
- def test_detached_source_round_trips_as_none(
+ def test_detached_source_round_trips_as_empty(
self,
tmp_path: Path,
reconstruction_factory: ReconstructionFactory,
@@ -99,7 +239,7 @@ def test_detached_source_round_trips_as_none(
reconstruction.save(path)
loaded = Reconstruction.load(path)
- assert loaded.audio_filepath is None
+ assert loaded.audio_filepath == ()
class TestDetachSource:
@@ -108,11 +248,11 @@ def test_detach_clears_the_source_location(
reconstruction_factory: ReconstructionFactory,
) -> None:
reconstruction = reconstruction_factory()
- assert reconstruction.audio_filepath is not None
+ assert reconstruction.audio_filepath
reconstruction.detach_source()
- assert reconstruction.audio_filepath is None
+ assert reconstruction.audio_filepath == ()
class TestLoadRejectsForeignFiles:
@@ -200,6 +340,79 @@ def test_foreign_application_name_propagates(
Reconstruction.load(path)
+class TestVersionUpgradeOnLoad:
+ def test_a_2_1_file_loads_through_the_upgrade(
+ self,
+ tmp_path: Path,
+ reconstruction_factory: ReconstructionFactory,
+ ) -> None:
+ reconstruction = reconstruction_factory()
+ path = tmp_path / "old.stn"
+ reconstruction.save(path)
+
+ binary = path.read_bytes()
+ data = msgpack.unpackb(binary, raw=False)
+ data["metadata"]["reconstruction_data_version"] = "2.1"
+ for item in data["approximations_data"]:
+ item["generator_name"] = item.pop("channel_name")
+
+ for item in data["instructions_data"]:
+ item["generator_name"] = item.pop("channel_name")
+
+ generation = data["config"]["generation"]
+ generation["generators"] = generation.pop("channels")
+ config_metadata = data["config"].get("metadata")
+ if isinstance(config_metadata, dict):
+ config_metadata["reconstruction_data_version"] = "2.1"
+
+ path.write_bytes(msgpack.packb(data, use_bin_type=True))
+
+ loaded = Reconstruction.load(path)
+
+ assert loaded.metadata.reconstruction_data_version == SAMPLETONES_RECONSTRUCTION_DATA_VERSION
+ assert loaded.config.metadata.reconstruction_data_version == SAMPLETONES_RECONSTRUCTION_DATA_VERSION
+ assert set(loaded.approximations) == set(reconstruction.approximations)
+ assert loaded.audio_filepath == reconstruction.audio_filepath
+ assert loaded.stems_data == reconstruction.stems_data
+
+ def test_a_2_1_file_without_stems_record_gains_the_single_entry_record(
+ self,
+ tmp_path: Path,
+ reconstruction_factory: ReconstructionFactory,
+ ) -> None:
+ reconstruction = reconstruction_factory()
+ path = tmp_path / "old_plain.stn"
+ reconstruction.save(path)
+
+ binary = path.read_bytes()
+ data = msgpack.unpackb(binary, raw=False)
+ data["metadata"]["reconstruction_data_version"] = "2.1"
+ data.pop("stems_data")
+ data["audio_filepath"] = str(reconstruction.audio_filepath[0])
+ for item in data["approximations_data"]:
+ item["generator_name"] = item.pop("channel_name")
+
+ for item in data["instructions_data"]:
+ item["generator_name"] = item.pop("channel_name")
+
+ generation = data["config"]["generation"]
+ generation["generators"] = generation.pop("channels")
+ config_metadata = data["config"].get("metadata")
+ if isinstance(config_metadata, dict):
+ config_metadata["reconstruction_data_version"] = "2.1"
+
+ path.write_bytes(msgpack.packb(data, use_bin_type=True))
+
+ loaded = Reconstruction.load(path)
+
+ stems_data = loaded.stems_data
+ assert stems_data.config.entries[0].id == 0
+ assert stems_data.config.entries[0].channels == list(loaded.config.generation.channels)
+ assert loaded.audio_filepath == reconstruction.audio_filepath
+ for channel, stem_ids in stems_data.assignments_by_channel.items():
+ assert len(stem_ids) == len(loaded.instructions[channel])
+
+
class TestDeserializeDataWrapping(BaseTestSuite):
@dataclass(frozen=True, kw_only=True)
class TestCase(BaseRegularTestCase):
@@ -243,7 +456,7 @@ class TestInitialPitchReference:
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
+ assert reconstruction.initial_pitches[ChannelName.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.
@@ -258,31 +471,31 @@ def test_export_measures_the_arpeggio_against_the_stored_reference(self) -> None
_pulse(_BASE_PITCH),
_pulse(_BASE_PITCH),
]
- reconstruction.update_generator_data(
- GeneratorName.PULSE1,
+ reconstruction.update_channel_data(
+ ChannelName.PULSE1,
arpeggiated,
np.ones(_AUDIO_LENGTH, dtype=np.float32),
_BASE_PITCH,
(),
)
- features = reconstruction.export()[GeneratorName.PULSE1]
+ features = reconstruction.export()[ChannelName.PULSE1]
assert features.initial_pitch == _BASE_PITCH
- assert features.arpeggio.tolist() == [_OCTAVE, 0]
+ assert list(features.arpeggio.items) == [_OCTAVE, 0]
def test_update_generator_data_replaces_the_reference(self) -> None:
reconstruction = _reconstruction([_pulse(_BASE_PITCH)])
- reconstruction.update_generator_data(
- GeneratorName.PULSE1,
+ reconstruction.update_channel_data(
+ ChannelName.PULSE1,
[_pulse(_RESET_PITCH)],
np.ones(_AUDIO_LENGTH, dtype=np.float32),
_RESET_PITCH,
(),
)
- assert reconstruction.initial_pitches[GeneratorName.PULSE1] == _RESET_PITCH
+ assert reconstruction.initial_pitches[ChannelName.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)])
@@ -295,46 +508,56 @@ def test_reference_survives_a_save_load_round_trip(self, tmp_path: Path) -> None
class TestHeldFeatures:
- """The dimensions each generator leaves to the channel travel with its instructions.
+ """The dimensions each channel leaves to the channel travel with its instructions.
A frame states every dimension, so an export reads which of them the instrument itself
wrote from the reconstruction rather than from the frames.
"""
- def test_a_fresh_reconstruction_writes_every_dimension(self) -> None:
+ def test_a_fresh_reconstruction_writes_every_dimension_it_chose(self) -> None:
reconstruction = _reconstruction([_pulse(_BASE_PITCH)])
- assert reconstruction.held_features[GeneratorName.PULSE1] == ()
+ assert reconstruction.held_features[ChannelName.PULSE1] == (
+ FeatureKey.PITCH,
+ FeatureKey.HI_PITCH,
+ )
+
+ def test_a_conversion_that_bent_a_note_writes_the_bend_it_made(self) -> None:
+ """A bend is a choice the conversion made, so the dimension carrying it is written."""
+ reconstruction = _reconstruction([_pulse(_BASE_PITCH).model_copy(update={"detune": 4})])
+
+ assert FeatureKey.PITCH not in reconstruction.held_features[ChannelName.PULSE1]
+ assert FeatureKey.HI_PITCH in reconstruction.held_features[ChannelName.PULSE1]
def test_a_held_dimension_exports_an_empty_envelope(self) -> None:
reconstruction = _reconstruction([_pulse(_BASE_PITCH)] * 3)
- reconstruction.update_generator_data(
- GeneratorName.PULSE1,
+ reconstruction.update_channel_data(
+ ChannelName.PULSE1,
[_pulse(_BASE_PITCH)] * 3,
np.ones(_AUDIO_LENGTH, dtype=np.float32),
_BASE_PITCH,
(FeatureKey.ARPEGGIO,),
)
- features = reconstruction.export()[GeneratorName.PULSE1]
- assert features.arpeggio.size == 0
- assert features.volume.size > 0
+ features = reconstruction.export()[ChannelName.PULSE1]
+ assert not features.arpeggio.written
+ assert features.volume.written
def test_the_written_dimensions_export_their_items(self) -> None:
reconstruction = _reconstruction([_pulse(_BASE_PITCH)] * 3)
- reconstruction.update_generator_data(
- GeneratorName.PULSE1,
+ reconstruction.update_channel_data(
+ ChannelName.PULSE1,
[_pulse(_BASE_PITCH)] * 3,
np.ones(_AUDIO_LENGTH, dtype=np.float32),
_BASE_PITCH,
(FeatureKey.ARPEGGIO,),
)
- features = reconstruction.export()[GeneratorName.PULSE1]
+ features = reconstruction.export()[ChannelName.PULSE1]
assert features.duty_cycle is not None
- assert features.duty_cycle.size > 0
+ assert features.duty_cycle.written
def test_the_record_reads_back_off_the_exported_envelopes(self) -> None:
"""What a reconstruction says it holds is what its export shows, on every channel.
@@ -344,8 +567,8 @@ def test_the_record_reads_back_off_the_exported_envelopes(self) -> None:
"""
reconstruction = _reconstruction([_pulse(_BASE_PITCH)] * 3)
- reconstruction.update_generator_data(
- GeneratorName.PULSE1,
+ reconstruction.update_channel_data(
+ ChannelName.PULSE1,
[_pulse(_BASE_PITCH)] * 3,
np.ones(_AUDIO_LENGTH, dtype=np.float32),
_BASE_PITCH,
@@ -354,40 +577,33 @@ def test_the_record_reads_back_off_the_exported_envelopes(self) -> None:
exported = reconstruction.export()
assert reconstruction.held_features == {
- generator_name: features.held_features for generator_name, features in exported.items()
+ channel_name: features.held_features for channel_name, features in exported.items()
}
def test_a_channel_standing_by_leaves_every_dimension_it_offers(self) -> None:
reconstruction = _reconstruction([_pulse(_BASE_PITCH)])
- assert reconstruction.held_features[GeneratorName.TRIANGLE] == (
- FeatureKey.VOLUME,
- FeatureKey.ARPEGGIO,
- )
- assert reconstruction.held_features[GeneratorName.NOISE] == (
- FeatureKey.VOLUME,
- FeatureKey.ARPEGGIO,
- FeatureKey.DUTY_CYCLE,
- )
+ for channel_name in (ChannelName.TRIANGLE, ChannelName.NOISE):
+ assert reconstruction.held_features[channel_name] == resting_held_features(channel_name)
def test_clearing_the_last_frame_records_what_standing_by_records(self) -> None:
"""A channel edited out of play reads the same as one that never played."""
reconstruction = _reconstruction([_pulse(_BASE_PITCH)] * 3)
- reconstruction.update_generator_data(
- GeneratorName.PULSE1,
+ reconstruction.update_channel_data(
+ ChannelName.PULSE1,
[],
np.zeros(0, dtype=np.float32),
- resting_reference(GeneratorName.PULSE1),
- (FeatureKey.VOLUME, FeatureKey.ARPEGGIO, FeatureKey.DUTY_CYCLE),
+ resting_reference(ChannelName.PULSE1),
+ resting_held_features(ChannelName.PULSE1),
)
- assert reconstruction.streams[GeneratorName.PULSE1] == InstructionsItem.resting(GeneratorName.PULSE1)
+ assert reconstruction.streams[ChannelName.PULSE1] == InstructionsItem.resting(ChannelName.PULSE1)
def test_held_dimensions_survive_a_save_load_round_trip(self, tmp_path: Path) -> None:
reconstruction = _reconstruction([_pulse(_BASE_PITCH)] * 3)
- reconstruction.update_generator_data(
- GeneratorName.PULSE1,
+ reconstruction.update_channel_data(
+ ChannelName.PULSE1,
[_pulse(_BASE_PITCH)] * 3,
np.ones(_AUDIO_LENGTH, dtype=np.float32),
_BASE_PITCH,
@@ -411,64 +627,64 @@ class TestChannelSet:
def test_a_fresh_reconstruction_holds_every_channel(self) -> None:
reconstruction = _reconstruction([_pulse(_BASE_PITCH)])
- assert set(reconstruction.instructions) == set(GeneratorName.items())
- assert reconstruction.playing_generators == (GeneratorName.PULSE1,)
+ assert set(reconstruction.instructions) == set(ChannelName.items())
+ assert reconstruction.playing_channels == (ChannelName.PULSE1,)
def test_a_channel_standing_by_rests_at_the_shared_reference(self) -> None:
reconstruction = _reconstruction([_pulse(_BASE_PITCH)])
- assert reconstruction.initial_pitches[GeneratorName.TRIANGLE] == resting_reference(GeneratorName.TRIANGLE)
- assert reconstruction.initial_pitches[GeneratorName.NOISE] == resting_reference(GeneratorName.NOISE)
+ assert reconstruction.initial_pitches[ChannelName.TRIANGLE] == resting_reference(ChannelName.TRIANGLE)
+ assert reconstruction.initial_pitches[ChannelName.NOISE] == resting_reference(ChannelName.NOISE)
def test_a_channel_standing_by_exports_empty_envelopes(self) -> None:
- features = _reconstruction([_pulse(_BASE_PITCH)]).export()[GeneratorName.PULSE2]
+ features = _reconstruction([_pulse(_BASE_PITCH)]).export()[ChannelName.PULSE2]
assert not features.has_frames
- assert features.volume.size == 0
- assert features.arpeggio.size == 0
+ assert not features.volume.written
+ assert not features.arpeggio.written
def test_a_channel_standing_by_renders_no_audio(self) -> None:
reconstruction = _reconstruction([_pulse(_BASE_PITCH)])
- assert GeneratorName.PULSE2 not in reconstruction.approximations
+ assert ChannelName.PULSE2 not in reconstruction.approximations
def test_clearing_every_frame_keeps_the_channel(self) -> None:
"""Taking a channel out of play leaves its stream in place, so the edit is reversible."""
reconstruction = _reconstruction([_pulse(_BASE_PITCH)] * 3)
- reconstruction.update_generator_data(
- GeneratorName.PULSE1,
+ reconstruction.update_channel_data(
+ ChannelName.PULSE1,
[],
np.zeros(0, dtype=np.float32),
_BASE_PITCH,
(FeatureKey.VOLUME, FeatureKey.ARPEGGIO, FeatureKey.DUTY_CYCLE),
)
- assert reconstruction.playing_generators == ()
- assert GeneratorName.PULSE1 in reconstruction.instructions
- assert reconstruction.initial_pitches[GeneratorName.PULSE1] == _BASE_PITCH
- assert not reconstruction.export()[GeneratorName.PULSE1].has_frames
+ assert reconstruction.playing_channels == ()
+ assert ChannelName.PULSE1 in reconstruction.instructions
+ assert reconstruction.initial_pitches[ChannelName.PULSE1] == _BASE_PITCH
+ assert not reconstruction.export()[ChannelName.PULSE1].has_frames
def test_a_frame_puts_a_channel_standing_by_into_play(self) -> None:
reconstruction = _reconstruction([_pulse(_BASE_PITCH)])
- reconstruction.update_generator_data(
- GeneratorName.PULSE2,
+ reconstruction.update_channel_data(
+ ChannelName.PULSE2,
[_pulse(_BASE_PITCH)] * 2,
np.ones(_AUDIO_LENGTH, dtype=np.float32),
_BASE_PITCH,
(),
)
- assert reconstruction.playing_generators == (GeneratorName.PULSE1, GeneratorName.PULSE2)
- assert reconstruction.export()[GeneratorName.PULSE2].has_frames
- assert GeneratorName.PULSE2 in reconstruction.approximations
+ assert reconstruction.playing_channels == (ChannelName.PULSE1, ChannelName.PULSE2)
+ assert reconstruction.export()[ChannelName.PULSE2].has_frames
+ assert ChannelName.PULSE2 in reconstruction.approximations
def test_a_reconstruction_of_channels_standing_by_stays_valid(self) -> None:
reconstruction = _reconstruction([_pulse(_BASE_PITCH)])
- reconstruction.update_generator_data(
- GeneratorName.PULSE1,
+ reconstruction.update_channel_data(
+ ChannelName.PULSE1,
[],
np.zeros(0, dtype=np.float32),
_BASE_PITCH,
@@ -485,30 +701,30 @@ def test_the_channel_set_survives_a_save_load_round_trip(self, tmp_path: Path) -
reconstruction.save(path)
loaded = Reconstruction.load(path)
- assert set(loaded.instructions) == set(GeneratorName.items())
- assert loaded.playing_generators == reconstruction.playing_generators
+ assert set(loaded.instructions) == set(ChannelName.items())
+ assert loaded.playing_channels == reconstruction.playing_channels
assert loaded.initial_pitches == reconstruction.initial_pitches
def test_a_file_storing_fewer_streams_reads_as_the_whole_channel_set(self, tmp_path: Path) -> None:
loaded = Reconstruction.load(_saved_playing_channels_only(tmp_path / "one_channel.stn"))
- assert set(loaded.instructions) == set(GeneratorName.items())
- assert loaded.playing_generators == (GeneratorName.PULSE1,)
- assert loaded.initial_pitches[GeneratorName.NOISE] == resting_reference(GeneratorName.NOISE)
- assert not loaded.export()[GeneratorName.TRIANGLE].has_frames
+ assert set(loaded.instructions) == set(ChannelName.items())
+ assert loaded.playing_channels == (ChannelName.PULSE1,)
+ assert loaded.initial_pitches[ChannelName.NOISE] == resting_reference(ChannelName.NOISE)
+ assert not loaded.export()[ChannelName.TRIANGLE].has_frames
def test_editing_such_a_file_writes_the_whole_channel_set(self, tmp_path: Path) -> None:
loaded = Reconstruction.load(_saved_playing_channels_only(tmp_path / "one_channel.stn"))
- loaded.update_generator_data(
- GeneratorName.PULSE2,
+ loaded.update_channel_data(
+ ChannelName.PULSE2,
[_pulse(_BASE_PITCH)],
np.ones(_AUDIO_LENGTH, dtype=np.float32),
_BASE_PITCH,
(),
)
- assert [item.generator_name for item in loaded.instructions_data] == list(GeneratorName.items())
+ assert [item.channel_name for item in loaded.instructions_data] == list(ChannelName.items())
class TestWithNesFrequency:
@@ -526,7 +742,7 @@ def test_resynthesizes_approximation_length(self, reconstruction_factory: Recons
retuned = reconstruction.with_nes_frequency(_RETUNED_FREQUENCY)
faster = reconstruction.with_nes_frequency(_FASTER_FREQUENCY)
- generator_approximation = retuned.approximations[GeneratorName.PULSE1]
+ generator_approximation = retuned.approximations[ChannelName.PULSE1]
assert len(retuned.approximation) == len(generator_approximation)
assert len(retuned.approximation) == retuned.config.frame_length
assert len(faster.approximation) < len(retuned.approximation)
@@ -558,15 +774,15 @@ def test_a_channel_standing_by_stays_standing_by(
retuned = reconstruction.with_nes_frequency(_RETUNED_FREQUENCY)
- assert set(retuned.approximations) == {GeneratorName.PULSE1}
- assert set(retuned.instructions) == set(GeneratorName.items())
- assert retuned.playing_generators == (GeneratorName.PULSE1,)
+ assert set(retuned.approximations) == {ChannelName.PULSE1}
+ assert set(retuned.instructions) == set(ChannelName.items())
+ assert retuned.playing_channels == (ChannelName.PULSE1,)
def test_a_reconstruction_of_channels_standing_by_retunes_to_silence(self) -> None:
"""Every channel standing by leaves nothing to render, and the retuned copy says so."""
reconstruction = _reconstruction([_pulse(_BASE_PITCH)])
- reconstruction.update_generator_data(
- GeneratorName.PULSE1,
+ reconstruction.update_channel_data(
+ ChannelName.PULSE1,
[],
np.zeros(0, dtype=np.float32),
_BASE_PITCH,
@@ -578,7 +794,7 @@ def test_a_reconstruction_of_channels_standing_by_retunes_to_silence(self) -> No
assert retuned.config.nes_frequency == _RETUNED_FREQUENCY
assert retuned.approximations == {}
assert retuned.approximation.size == 0
- assert retuned.playing_generators == ()
+ assert retuned.playing_channels == ()
def test_matching_rate_returns_self(self, reconstruction_factory: ReconstructionFactory) -> None:
reconstruction = reconstruction_factory()
diff --git a/tests/unit/sampletones_core/reconstructions/reconstruction/test_stems_filter.py b/tests/unit/sampletones_core/reconstructions/reconstruction/test_stems_filter.py
new file mode 100644
index 000000000..0186e7e08
--- /dev/null
+++ b/tests/unit/sampletones_core/reconstructions/reconstruction/test_stems_filter.py
@@ -0,0 +1,138 @@
+from typing import Final, List, Tuple
+
+import numpy as np
+
+from sampletones_core.constants.enums import ChannelName
+from sampletones_core.reconstructions.reconstruction.stems.channel_assignment import ChannelAssignment
+from sampletones_core.reconstructions.reconstruction.stems.data import StemsData
+from sampletones_core.reconstructions.reconstruction.stems.filter import (
+ filter_approximations,
+)
+from sampletones_core.reconstructions.reconstruction.stems.selection import StemSelection
+from sampletones_core.reconstructions.reconstructor.stems.configs.config import StemsConfig
+from sampletones_core.reconstructions.reconstructor.stems.configs.entry import StemEntry
+from sampletones_core.reconstructions.reconstructor.stems.configs.hierarchy import StemsHierarchy
+
+FRAME_LENGTH: Final[int] = 2
+EVERY_CHANNEL: Final[Tuple[ChannelName, ...]] = tuple(ChannelName.items())
+
+
+def _heard(*stem_ids: int) -> StemSelection:
+ """The selection hearing every named stem on every channel."""
+ return StemSelection.everywhere(frozenset(stem_ids), EVERY_CHANNEL)
+
+
+def _stems_data(*stem_lists: Tuple[ChannelName, List[int]]) -> StemsData:
+ entries = [StemEntry(id=stem_id, channels=[ChannelName.PULSE1]) for stem_id in range(3)]
+ return StemsData(
+ config=StemsConfig(
+ entries=entries,
+ hierarchy=StemsHierarchy(levels=[[entry.id] for entry in entries]),
+ ),
+ assignments=[ChannelAssignment(channel_name=channel, stem_ids=stem_ids) for channel, stem_ids in stem_lists],
+ )
+
+
+class TestFilterApproximations:
+ def test_unselected_frames_become_silence(self) -> None:
+ stems_data = _stems_data((ChannelName.PULSE1, [0, 1, 0, 2]))
+ approximations = {
+ ChannelName.PULSE1: np.arange(8, dtype=np.float32),
+ }
+
+ filtered = filter_approximations(
+ stems_data,
+ approximations,
+ _heard(0, 2),
+ FRAME_LENGTH,
+ )
+
+ expected = np.array([0, 1, 0, 0, 4, 5, 6, 7], dtype=np.float32)
+ np.testing.assert_array_equal(filtered[ChannelName.PULSE1], expected)
+ assert len(filtered[ChannelName.PULSE1]) == len(approximations[ChannelName.PULSE1])
+
+ def test_every_selected_stem_answers_the_original_arrays(self) -> None:
+ stems_data = _stems_data((ChannelName.PULSE1, [0, 1, 2]))
+ approximations = {
+ ChannelName.PULSE1: np.arange(6, dtype=np.float32),
+ }
+
+ filtered = filter_approximations(
+ stems_data,
+ approximations,
+ _heard(0, 1, 2),
+ FRAME_LENGTH,
+ )
+
+ np.testing.assert_array_equal(filtered[ChannelName.PULSE1], approximations[ChannelName.PULSE1])
+
+ def test_no_selected_stem_is_silence(self) -> None:
+ stems_data = _stems_data((ChannelName.PULSE1, [0, 1]))
+ approximations = {
+ ChannelName.PULSE1: np.ones(4, dtype=np.float32),
+ }
+
+ filtered = filter_approximations(
+ stems_data,
+ approximations,
+ _heard(),
+ FRAME_LENGTH,
+ )
+
+ np.testing.assert_array_equal(filtered[ChannelName.PULSE1], np.zeros(4, dtype=np.float32))
+
+ def test_the_stored_arrays_keep_their_samples(self) -> None:
+ stems_data = _stems_data((ChannelName.PULSE1, [0, 1]))
+ approximations = {
+ ChannelName.PULSE1: np.ones(4, dtype=np.float32),
+ }
+
+ filter_approximations(
+ stems_data,
+ approximations,
+ _heard(0),
+ FRAME_LENGTH,
+ )
+
+ np.testing.assert_array_equal(approximations[ChannelName.PULSE1], np.ones(4, dtype=np.float32))
+
+ def test_channels_the_stems_data_names_come_back_filtered(self) -> None:
+ stems_data = _stems_data(
+ (ChannelName.PULSE1, [0]),
+ (ChannelName.NOISE, [1]),
+ )
+ approximations = {
+ ChannelName.PULSE1: np.ones(2, dtype=np.float32),
+ ChannelName.NOISE: np.ones(2, dtype=np.float32),
+ }
+
+ filtered = filter_approximations(
+ stems_data,
+ approximations,
+ _heard(1),
+ FRAME_LENGTH,
+ )
+
+ assert set(filtered) == {ChannelName.PULSE1, ChannelName.NOISE}
+ np.testing.assert_array_equal(filtered[ChannelName.PULSE1], np.zeros(2, dtype=np.float32))
+ np.testing.assert_array_equal(filtered[ChannelName.NOISE], np.ones(2, dtype=np.float32))
+
+ def test_a_stem_heard_on_one_channel_stays_quiet_on_the_other(self) -> None:
+ stems_data = _stems_data(
+ (ChannelName.PULSE1, [0]),
+ (ChannelName.NOISE, [0]),
+ )
+ approximations = {
+ ChannelName.PULSE1: np.ones(2, dtype=np.float32),
+ ChannelName.NOISE: np.ones(2, dtype=np.float32),
+ }
+
+ filtered = filter_approximations(
+ stems_data,
+ approximations,
+ StemSelection(channels={ChannelName.PULSE1: frozenset({0}), ChannelName.NOISE: frozenset()}),
+ FRAME_LENGTH,
+ )
+
+ np.testing.assert_array_equal(filtered[ChannelName.PULSE1], np.ones(2, dtype=np.float32))
+ np.testing.assert_array_equal(filtered[ChannelName.NOISE], np.zeros(2, dtype=np.float32))
diff --git a/tests/unit/sampletones_core/reconstructions/reconstruction/test_stems_removal.py b/tests/unit/sampletones_core/reconstructions/reconstruction/test_stems_removal.py
new file mode 100644
index 000000000..39e4a123f
--- /dev/null
+++ b/tests/unit/sampletones_core/reconstructions/reconstruction/test_stems_removal.py
@@ -0,0 +1,318 @@
+from pathlib import Path
+from typing import Dict, Final, List, Mapping, Sequence
+
+import numpy as np
+import pytest
+
+from sampletones_core.configs import Config
+from sampletones_core.constants.algorithm import RESTING_STEM_ID
+from sampletones_core.constants.enums import ChannelName, HierarchyMode
+from sampletones_core.instructions import InstructionUnion, NoiseInstruction, PulseInstruction
+from sampletones_core.reconstructions.reconstruction.reconstruction import Reconstruction
+from sampletones_core.reconstructions.reconstruction.stems.channel_assignment import ChannelAssignment
+from sampletones_core.reconstructions.reconstruction.stems.data import StemsData
+from sampletones_core.reconstructions.reconstruction.stems.removal import without_stem
+from sampletones_core.reconstructions.reconstructor.stems.configs.config import StemsConfig
+from sampletones_core.reconstructions.reconstructor.stems.configs.entry import StemEntry
+from sampletones_core.reconstructions.reconstructor.stems.configs.hierarchy import StemsHierarchy
+
+STEM_A: Final[int] = 0
+STEM_B: Final[int] = 1
+STEM_C: Final[int] = 2
+FRAME_COUNT: Final[int] = 4
+PULSE_LEVEL: Final[float] = 3.0
+NOISE_LEVEL: Final[float] = 2.0
+
+RECORDINGS: Final[Dict[int, Path]] = {
+ STEM_A: Path("/recordings/a.wav"),
+ STEM_B: Path("/recordings/b.wav"),
+ STEM_C: Path("/recordings/c.wav"),
+}
+
+
+def _pulse(pitch: int) -> PulseInstruction:
+ return PulseInstruction(on=True, pitch=pitch, volume=8, duty_cycle=0)
+
+
+def _noise() -> NoiseInstruction:
+ return NoiseInstruction(on=True, period=4, volume=8, short=False)
+
+
+def _stems_config() -> StemsConfig:
+ return StemsConfig(
+ entries=[
+ StemEntry(id=STEM_A, channels=[ChannelName.PULSE1]),
+ StemEntry(id=STEM_B, channels=[ChannelName.PULSE1, ChannelName.NOISE]),
+ StemEntry(id=STEM_C, channels=[ChannelName.PULSE1]),
+ ],
+ hierarchy=StemsHierarchy(
+ levels=[[STEM_A], [STEM_B, STEM_C]],
+ mode=HierarchyMode.STRICT,
+ ),
+ channel_cap=1,
+ )
+
+
+def _reconstruction(
+ owners: Mapping[ChannelName, Sequence[int]],
+ *,
+ instructions: Mapping[ChannelName, Sequence[InstructionUnion]],
+ approximations: Mapping[ChannelName, np.ndarray],
+) -> Reconstruction:
+ """A three-recording reconstruction whose frames are owned as ``owners`` states."""
+ return Reconstruction.create(
+ approximation=np.zeros(0, dtype=np.float32),
+ approximations=approximations,
+ instructions=instructions,
+ config=Config(),
+ coefficient=1.0,
+ audio_filepath=tuple(RECORDINGS[stem_id] for stem_id in (STEM_A, STEM_B, STEM_C)),
+ stems_data=StemsData(
+ config=_stems_config(),
+ assignments=[
+ ChannelAssignment(channel_name=channel_name, stem_ids=list(stem_ids))
+ for channel_name, stem_ids in owners.items()
+ ],
+ ),
+ )
+
+
+def _frame_length() -> int:
+ return Config().library.frame_length
+
+
+def _audio(level: float) -> np.ndarray:
+ return np.full(FRAME_COUNT * _frame_length(), level, dtype=np.float32)
+
+
+def _frame(audio: np.ndarray, index: int) -> np.ndarray:
+ frame_length = _frame_length()
+ return audio[index * frame_length : (index + 1) * frame_length]
+
+
+def _sounding(reconstruction: Reconstruction, channel_name: ChannelName) -> List[bool]:
+ """Whether each frame of a channel states a sounding instruction."""
+ return [instruction.on for instruction in reconstruction.instructions[channel_name]]
+
+
+@pytest.fixture
+def reconstruction() -> Reconstruction:
+ """Three recordings over two channels: the noise channel belongs to stem B throughout.
+
+ Stem B holds the whole noise channel and the second pulse frame, so removing it both
+ releases single frames and empties a channel — the two outcomes a removal has to answer.
+ """
+ return _reconstruction(
+ {
+ ChannelName.PULSE1: [STEM_A, STEM_B, STEM_C, RESTING_STEM_ID],
+ ChannelName.NOISE: [STEM_B] * FRAME_COUNT,
+ },
+ instructions={
+ ChannelName.PULSE1: [_pulse(60), _pulse(61), _pulse(62), _pulse(63)],
+ ChannelName.NOISE: [_noise() for _ in range(FRAME_COUNT)],
+ },
+ approximations={
+ ChannelName.PULSE1: _audio(PULSE_LEVEL),
+ ChannelName.NOISE: _audio(NOISE_LEVEL),
+ },
+ )
+
+
+class TestTheRecordedSetup:
+ def test_the_removed_recording_leaves_the_entries(self, reconstruction: Reconstruction) -> None:
+ remaining = without_stem(reconstruction, STEM_C)
+
+ assert [entry.id for entry in remaining.stems_data.config.entries] == [STEM_A, STEM_B]
+
+ def test_the_removed_recording_leaves_its_level(self, reconstruction: Reconstruction) -> None:
+ remaining = without_stem(reconstruction, STEM_C)
+
+ assert remaining.stems_data.config.hierarchy.levels == [[STEM_A], [STEM_B]]
+
+ def test_a_level_the_removal_empties_goes_along_with_it(self, reconstruction: Reconstruction) -> None:
+ remaining = without_stem(reconstruction, STEM_A)
+
+ assert remaining.stems_data.config.hierarchy.levels == [[STEM_B, STEM_C]]
+
+ def test_the_picking_order_and_the_cap_carry_over(self, reconstruction: Reconstruction) -> None:
+ remaining = without_stem(reconstruction, STEM_C)
+
+ assert remaining.stems_data.config.hierarchy.mode == HierarchyMode.STRICT
+ assert remaining.stems_data.config.channel_cap == 1
+
+ def test_the_removed_recordings_source_path_goes_from_its_position(
+ self,
+ reconstruction: Reconstruction,
+ ) -> None:
+ remaining = without_stem(reconstruction, STEM_B)
+
+ assert remaining.audio_filepath == (RECORDINGS[STEM_A], RECORDINGS[STEM_C])
+
+ def test_a_detached_reconstruction_stays_detached(self, reconstruction: Reconstruction) -> None:
+ reconstruction.detach_source()
+
+ remaining = without_stem(reconstruction, STEM_B)
+
+ assert remaining.audio_filepath == ()
+
+
+class TestTheReleasedFrames:
+ def test_the_frames_it_held_rest_in_the_assignment(self, reconstruction: Reconstruction) -> None:
+ remaining = without_stem(reconstruction, STEM_B)
+
+ assert remaining.stems_data.assignments_by_channel[ChannelName.PULSE1] == [
+ STEM_A,
+ RESTING_STEM_ID,
+ STEM_C,
+ RESTING_STEM_ID,
+ ]
+
+ def test_the_frames_it_held_state_silence(self, reconstruction: Reconstruction) -> None:
+ remaining = without_stem(reconstruction, STEM_B)
+
+ assert _sounding(remaining, ChannelName.PULSE1) == [True, False, True, True]
+
+ def test_the_frames_it_held_lose_their_samples(self, reconstruction: Reconstruction) -> None:
+ remaining = without_stem(reconstruction, STEM_B)
+
+ audio = remaining.approximations[ChannelName.PULSE1]
+ np.testing.assert_array_equal(_frame(audio, 1), np.zeros(_frame_length(), dtype=np.float32))
+
+ def test_the_recordings_that_stay_keep_their_frames(self, reconstruction: Reconstruction) -> None:
+ remaining = without_stem(reconstruction, STEM_B)
+
+ audio = remaining.approximations[ChannelName.PULSE1]
+ kept = np.full(_frame_length(), PULSE_LEVEL, dtype=np.float32)
+ for index in (0, 2, 3):
+ np.testing.assert_array_equal(_frame(audio, index), kept)
+
+ def test_the_channel_keeps_its_length(self, reconstruction: Reconstruction) -> None:
+ remaining = without_stem(reconstruction, STEM_B)
+
+ assert len(remaining.approximations[ChannelName.PULSE1]) == FRAME_COUNT * _frame_length()
+
+ def test_a_channel_the_removal_leaves_alone_keeps_its_audio(self, reconstruction: Reconstruction) -> None:
+ remaining = without_stem(reconstruction, STEM_C)
+
+ np.testing.assert_array_equal(
+ remaining.approximations[ChannelName.NOISE],
+ _audio(NOISE_LEVEL),
+ )
+
+
+class TestAnEmptiedChannel:
+ def test_a_channel_left_entirely_released_stands_by(self, reconstruction: Reconstruction) -> None:
+ remaining = without_stem(reconstruction, STEM_B)
+
+ assert remaining.playing_channels == (ChannelName.PULSE1,)
+
+ def test_a_channel_left_entirely_released_sounds_nothing(self, reconstruction: Reconstruction) -> None:
+ remaining = without_stem(reconstruction, STEM_B)
+
+ np.testing.assert_array_equal(
+ remaining.approximations[ChannelName.NOISE],
+ np.zeros(FRAME_COUNT * _frame_length(), dtype=np.float32),
+ )
+
+ def test_a_channel_left_entirely_released_rests_through_every_frame(
+ self,
+ reconstruction: Reconstruction,
+ ) -> None:
+ remaining = without_stem(reconstruction, STEM_B)
+
+ assert remaining.stems_data.assignments_by_channel[ChannelName.NOISE] == [RESTING_STEM_ID] * FRAME_COUNT
+
+ def test_a_channel_that_already_rested_throughout_keeps_its_stream(self) -> None:
+ """A removal reaching none of a channel's frames leaves that channel exactly as it stood."""
+ reconstruction = _reconstruction(
+ {
+ ChannelName.PULSE1: [STEM_A, STEM_B, STEM_C, RESTING_STEM_ID],
+ ChannelName.NOISE: [RESTING_STEM_ID] * FRAME_COUNT,
+ },
+ instructions={
+ ChannelName.PULSE1: [_pulse(60), _pulse(61), _pulse(62), _pulse(63)],
+ ChannelName.NOISE: [_noise() for _ in range(FRAME_COUNT)],
+ },
+ approximations={
+ ChannelName.PULSE1: _audio(PULSE_LEVEL),
+ ChannelName.NOISE: _audio(NOISE_LEVEL),
+ },
+ )
+
+ remaining = without_stem(reconstruction, STEM_B)
+
+ assert _sounding(remaining, ChannelName.NOISE) == [True] * FRAME_COUNT
+ np.testing.assert_array_equal(remaining.approximations[ChannelName.NOISE], _audio(NOISE_LEVEL))
+
+ def test_a_channel_an_edit_re_derived_keeps_its_stream(self) -> None:
+ """A channel the assignment says nothing about is the editor's, so a removal passes it by."""
+ reconstruction = _reconstruction(
+ {ChannelName.PULSE1: [STEM_A, STEM_B, STEM_C, RESTING_STEM_ID]},
+ instructions={
+ ChannelName.PULSE1: [_pulse(60), _pulse(61), _pulse(62), _pulse(63)],
+ ChannelName.NOISE: [_noise() for _ in range(FRAME_COUNT)],
+ },
+ approximations={
+ ChannelName.PULSE1: _audio(PULSE_LEVEL),
+ ChannelName.NOISE: _audio(NOISE_LEVEL),
+ },
+ )
+
+ remaining = without_stem(reconstruction, STEM_B)
+
+ assert _sounding(remaining, ChannelName.NOISE) == [True] * FRAME_COUNT
+ np.testing.assert_array_equal(remaining.approximations[ChannelName.NOISE], _audio(NOISE_LEVEL))
+
+
+class TestTheMixedApproximation:
+ def test_the_mix_is_summed_afresh_from_what_stays(self, reconstruction: Reconstruction) -> None:
+ remaining = without_stem(reconstruction, STEM_B)
+
+ expected = np.full(FRAME_COUNT * _frame_length(), PULSE_LEVEL, dtype=np.float32)
+ expected[_frame_length() : 2 * _frame_length()] = 0.0
+ np.testing.assert_array_equal(remaining.approximation, expected)
+
+
+class TestTheDocument:
+ def test_the_document_keeps_its_identity(self, reconstruction: Reconstruction) -> None:
+ remaining = without_stem(reconstruction, STEM_C)
+
+ assert remaining.id == reconstruction.id
+ assert remaining.config == reconstruction.config
+ assert remaining.coefficient == reconstruction.coefficient
+ assert remaining.metadata == reconstruction.metadata
+
+ def test_the_source_reconstruction_is_left_as_it_stood(self, reconstruction: Reconstruction) -> None:
+ without_stem(reconstruction, STEM_B)
+
+ assert [entry.id for entry in reconstruction.stems_data.config.entries] == [STEM_A, STEM_B, STEM_C]
+ assert reconstruction.stems_data.assignments_by_channel[ChannelName.NOISE] == [STEM_B] * FRAME_COUNT
+ np.testing.assert_array_equal(reconstruction.approximations[ChannelName.NOISE], _audio(NOISE_LEVEL))
+
+
+class TestARefusedRemoval:
+ def test_removing_an_unrecorded_stem_is_refused(self, reconstruction: Reconstruction) -> None:
+ with pytest.raises(ValueError, match="names no entry"):
+ without_stem(reconstruction, 7)
+
+ def test_removing_the_last_recording_is_refused(self) -> None:
+ reconstruction = Reconstruction.create(
+ approximation=np.zeros(0, dtype=np.float32),
+ approximations={ChannelName.PULSE1: _audio(PULSE_LEVEL)},
+ instructions={ChannelName.PULSE1: [_pulse(60)] * FRAME_COUNT},
+ config=Config(),
+ coefficient=1.0,
+ audio_filepath=(RECORDINGS[STEM_A],),
+ stems_data=StemsData.single_entry(
+ [ChannelName.PULSE1],
+ [
+ ChannelAssignment(
+ channel_name=ChannelName.PULSE1,
+ stem_ids=[STEM_A] * FRAME_COUNT,
+ )
+ ],
+ ),
+ )
+
+ with pytest.raises(ValueError, match="at least one stem"):
+ without_stem(reconstruction, STEM_A)
diff --git a/tests/unit/sampletones_core/reconstructions/reconstructor/conftest.py b/tests/unit/sampletones_core/reconstructions/reconstructor/conftest.py
index 5f58d61a6..dcb80d9ac 100644
--- a/tests/unit/sampletones_core/reconstructions/reconstructor/conftest.py
+++ b/tests/unit/sampletones_core/reconstructions/reconstructor/conftest.py
@@ -1,14 +1,15 @@
-from typing import Any, Dict, Final
+from typing import Any, Dict, Final, List
import numpy as np
import pytest
from sampletones_core.configs import Config
-from sampletones_core.constants.enums import GeneratorName
+from sampletones_core.constants.algorithm import STEM_ACTIVITY_FLOOR
+from sampletones_core.constants.enums import ChannelName
from sampletones_core.fft import Fragment, Window
from sampletones_core.fft.features import FeatureExtractor, get_feature_extractor
from sampletones_core.fft.fragment.audio import FragmentedAudio
-from sampletones_core.generators import GeneratorUnion, get_generators_by_names
+from sampletones_core.generators import GeneratorUnion, get_generators_by_channels
from sampletones_core.instructions import InstructionUnion
from sampletones_core.library import InstructionLibraryData, InstructionLibraryFragment
from sampletones_core.reconstructions.reconstructor.worker import ReconstructorWorker
@@ -33,18 +34,18 @@ def extractor(config: Config, window: Window) -> FeatureExtractor:
@pytest.fixture(scope="module")
-def generators(config: Config) -> Dict[GeneratorName, GeneratorUnion]:
- return get_generators_by_names(config, config.generation.generators)
+def channels(config: Config) -> Dict[ChannelName, GeneratorUnion]:
+ return get_generators_by_channels(config, config.generation.channels)
@pytest.fixture(scope="module")
def library_data(
config: Config,
extractor: FeatureExtractor,
- generators: Dict[GeneratorName, GeneratorUnion],
+ channels: Dict[ChannelName, GeneratorUnion],
) -> InstructionLibraryData:
data: Dict[InstructionUnion, InstructionLibraryFragment[Any]] = {}
- for generator in generators.values():
+ for generator in channels.values():
for instruction in list(generator.get_possible_instructions())[:INSTRUCTIONS_PER_GENERATOR_IN_TEST_LIBRARY]:
data[instruction] = InstructionLibraryFragment.create(generator, instruction, extractor)
@@ -55,13 +56,13 @@ def library_data(
def worker(
config: Config,
window: Window,
- generators: Dict[GeneratorName, GeneratorUnion],
+ channels: Dict[ChannelName, GeneratorUnion],
library_data: InstructionLibraryData,
) -> ReconstructorWorker:
return ReconstructorWorker(
config=config,
window=window,
- generators=generators,
+ channels=channels,
library_data=library_data,
signal_length=WORKER_SIGNAL_LENGTH,
)
@@ -82,8 +83,32 @@ def synthetic_fragment(
config: Config,
window: Window,
) -> Fragment:
- active_instruction = next(instrument for instrument in library_data.keys() if instrument.on)
- return library_data[active_instruction].get_fragment(0, config, window)
+ """A frame of sound a channel renders, the stand-in for what one stem contributes."""
+ return _renderable_fragments(library_data, config, window)[0]
+
+
+@pytest.fixture
+def audible_fragments(
+ library_data: InstructionLibraryData,
+ config: Config,
+ window: Window,
+) -> List[Fragment]:
+ """The distinct sounds a case hands its stems, each loud enough for a channel to render."""
+ return _renderable_fragments(library_data, config, window)
+
+
+def _renderable_fragments(
+ library_data: InstructionLibraryData,
+ config: Config,
+ window: Window,
+) -> List[Fragment]:
+ """The library's frames loud enough to render, which is what the assignment asks of a stem."""
+ fragments = [
+ library_data[instruction].get_fragment(0, config, window)
+ for instruction in library_data.keys()
+ if instruction.on
+ ]
+ return [fragment for fragment in fragments if float(np.max(np.abs(fragment.audio))) > STEM_ACTIVITY_FLOOR]
@pytest.fixture
diff --git a/tests/unit/sampletones_core/reconstructions/reconstructor/decoder/__init__.py b/tests/unit/sampletones_core/reconstructions/reconstructor/decoder/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/tests/unit/sampletones_core/reconstructions/reconstructor/decoder/conftest.py b/tests/unit/sampletones_core/reconstructions/reconstructor/decoder/conftest.py
new file mode 100644
index 000000000..ae7f3aced
--- /dev/null
+++ b/tests/unit/sampletones_core/reconstructions/reconstructor/decoder/conftest.py
@@ -0,0 +1,42 @@
+from typing import Any, List
+
+import pytest
+
+from sampletones_core.configs import Config
+from sampletones_core.instructions import PulseInstruction
+from sampletones_core.reconstructions.reconstructor.decoder.base import ChannelLattice
+from sampletones_core.reconstructions.reconstructor.decoder.greedy import GreedyDecoder
+from sampletones_core.reconstructions.reconstructor.decoder.viterbi import ViterbiDecoder
+from sampletones_core.reconstructions.reconstructor.matching import ScoredCandidate
+
+STEADY = PulseInstruction(on=True, pitch=60, volume=10, duty_cycle=0)
+JUMPED = PulseInstruction(on=True, pitch=72, volume=10, duty_cycle=0)
+
+
+def state(instruction: PulseInstruction, cost: float) -> ScoredCandidate:
+ return ScoredCandidate(instruction=instruction, cost=cost, approximation=None) # type: ignore[arg-type]
+
+
+def per_frame_best(frames: ChannelLattice) -> List[PulseInstruction]:
+ return [min(frame, key=lambda candidate: candidate.cost).instruction for frame in frames]
+
+
+@pytest.fixture
+def flickering_frames() -> ChannelLattice:
+ """Three frames whose per-frame best alternates, while one instruction stays cheap throughout."""
+ return [
+ (state(STEADY, 0.00), state(JUMPED, 0.05)),
+ (state(STEADY, 0.05), state(JUMPED, 0.00)),
+ (state(STEADY, 0.00), state(JUMPED, 0.05)),
+ ]
+
+
+@pytest.fixture
+def greedy_decoder(config: Config) -> GreedyDecoder:
+ return GreedyDecoder(config)
+
+
+def viterbi_decoder(config: Config, **decoder_overrides: Any) -> ViterbiDecoder:
+ decoder = config.generation.decoder.model_copy(update=decoder_overrides)
+ updated_config = config.model_copy(update={"generation": config.generation.model_copy(update={"decoder": decoder})})
+ return ViterbiDecoder(updated_config)
diff --git a/tests/unit/sampletones_core/reconstructions/reconstructor/decoder/test_decoders.py b/tests/unit/sampletones_core/reconstructions/reconstructor/decoder/test_decoders.py
new file mode 100644
index 000000000..3e7661d13
--- /dev/null
+++ b/tests/unit/sampletones_core/reconstructions/reconstructor/decoder/test_decoders.py
@@ -0,0 +1,72 @@
+import pytest
+
+from sampletones_core.configs import Config
+from sampletones_core.constants.algorithm import SINGLE_STATE_LATTICE_WIDTH
+from sampletones_core.constants.enums import ChannelName, SelectorName
+from sampletones_core.reconstructions.reconstructor.decoder import DECODERS
+from sampletones_core.reconstructions.reconstructor.decoder.base import ChannelLattice, Lattices
+from sampletones_core.reconstructions.reconstructor.decoder.greedy import GreedyDecoder
+
+from .conftest import JUMPED, STEADY, per_frame_best, state
+
+
+class TestDecoderCatalog:
+ def test_every_selector_name_is_answered(self) -> None:
+ assert set(DECODERS) == set(SelectorName)
+
+ @pytest.mark.parametrize("selector_name", list(SelectorName))
+ def test_a_decoder_reads_at_least_one_candidate_per_frame(
+ self,
+ selector_name: SelectorName,
+ config: Config,
+ ) -> None:
+ assert DECODERS[selector_name](config).lattice_width >= SINGLE_STATE_LATTICE_WIDTH
+
+
+class TestDecodedShape:
+ @staticmethod
+ def _lattices() -> Lattices:
+ return {
+ ChannelName.PULSE1: [(state(STEADY, 0.1),), (state(STEADY, 0.2),)],
+ ChannelName.TRIANGLE: [(state(STEADY, 0.3),), (state(STEADY, 0.4),)],
+ }
+
+ @pytest.mark.parametrize("selector_name", list(SelectorName))
+ def test_every_channel_answers_each_of_its_frames(
+ self,
+ selector_name: SelectorName,
+ config: Config,
+ ) -> None:
+ lattices = self._lattices()
+
+ streams = DECODERS[selector_name](config).decode(lattices)
+
+ assert set(streams) == set(lattices)
+ assert {name: len(stream) for name, stream in streams.items()} == {
+ name: len(frames) for name, frames in lattices.items()
+ }
+
+ @pytest.mark.parametrize("selector_name", list(SelectorName))
+ def test_a_channel_with_no_frames_answers_nothing(
+ self,
+ selector_name: SelectorName,
+ config: Config,
+ ) -> None:
+ assert DECODERS[selector_name](config).decode({ChannelName.PULSE1: []}) == {ChannelName.PULSE1: []}
+
+
+class TestGreedyDecoder:
+ def test_reads_one_candidate_per_frame(self, greedy_decoder: GreedyDecoder) -> None:
+ assert greedy_decoder.lattice_width == SINGLE_STATE_LATTICE_WIDTH
+
+ def test_plays_the_head_of_each_column(self, greedy_decoder: GreedyDecoder) -> None:
+ """A column arrives best first, so its head is what the frame's own cost chose."""
+ frames: ChannelLattice = [
+ (state(STEADY, 0.00), state(JUMPED, 0.05)),
+ (state(JUMPED, 0.00), state(STEADY, 0.05)),
+ ]
+
+ streams = greedy_decoder.decode({ChannelName.PULSE1: frames})
+
+ assert [candidate.instruction for candidate in streams[ChannelName.PULSE1]] == per_frame_best(frames)
+ assert [candidate.instruction for candidate in streams[ChannelName.PULSE1]] == [STEADY, JUMPED]
diff --git a/tests/unit/sampletones_core/reconstructions/reconstructor/decoder/test_viterbi.py b/tests/unit/sampletones_core/reconstructions/reconstructor/decoder/test_viterbi.py
new file mode 100644
index 000000000..aea17cd00
--- /dev/null
+++ b/tests/unit/sampletones_core/reconstructions/reconstructor/decoder/test_viterbi.py
@@ -0,0 +1,67 @@
+from sampletones_core.configs import Config
+from sampletones_core.constants.enums import ChannelName
+from sampletones_core.instructions import PulseInstruction
+from sampletones_core.reconstructions.reconstructor.decoder.base import ChannelLattice
+
+from .conftest import STEADY, per_frame_best, viterbi_decoder
+
+
+class TestViterbiContinuity:
+ def test_continuity_holds_a_steady_note_where_per_frame_choice_flickers(
+ self,
+ config: Config,
+ flickering_frames: ChannelLattice,
+ ) -> None:
+ decoder = viterbi_decoder(config, pitch_weight=1.0)
+
+ streams = decoder.decode({ChannelName.PULSE1: flickering_frames})
+ chosen = [candidate.instruction for candidate in streams[ChannelName.PULSE1]]
+
+ assert len(set(per_frame_best(flickering_frames))) > 1
+ assert len(set(chosen)) == 1
+
+ def test_zero_transition_weights_reduce_to_per_frame_choice(
+ self,
+ config: Config,
+ flickering_frames: ChannelLattice,
+ ) -> None:
+ decoder = viterbi_decoder(
+ config,
+ pitch_weight=0.0,
+ volume_weight=0.0,
+ timbre_weight=0.0,
+ on_off_weight=0.0,
+ )
+
+ streams = decoder.decode({ChannelName.PULSE1: flickering_frames})
+
+ assert [candidate.instruction for candidate in streams[ChannelName.PULSE1]] == per_frame_best(flickering_frames)
+
+
+class TestViterbiLatticeWidth:
+ def test_reads_as_many_candidates_as_the_configured_shortlist(self, config: Config) -> None:
+ assert viterbi_decoder(config).lattice_width == config.generation.decoder.top_k
+
+
+class TestViterbiTransitionCost:
+ def test_identical_instruction_has_no_cost(self, config: Config) -> None:
+ decoder = viterbi_decoder(config)
+ assert decoder._transition_cost(STEADY, STEADY) == 0.0
+
+ def test_larger_pitch_jump_costs_more(self, config: Config) -> None:
+ decoder = viterbi_decoder(config, pitch_weight=0.1)
+ near = PulseInstruction(on=True, pitch=61, volume=10, duty_cycle=0)
+ far = PulseInstruction(on=True, pitch=84, volume=10, duty_cycle=0)
+ assert decoder._transition_cost(STEADY, near) < decoder._transition_cost(STEADY, far)
+
+ def test_toggling_on_off_costs_the_on_off_weight(self, config: Config) -> None:
+ decoder = viterbi_decoder(config, on_off_weight=0.25)
+ silence = PulseInstruction(on=False, pitch=60, volume=0, duty_cycle=0)
+ assert decoder._transition_cost(STEADY, silence) == 0.25
+
+ def test_a_resting_frame_costs_the_on_off_weight_to_reach(self, config: Config) -> None:
+ """A rest is an off state, so a channel pays the same to fall silent as to start."""
+ decoder = viterbi_decoder(config, on_off_weight=0.25)
+ resting = PulseInstruction.null_instruction()
+ assert decoder._transition_cost(STEADY, resting) == 0.25
+ assert decoder._transition_cost(resting, resting) == 0.0
diff --git a/tests/unit/sampletones_core/reconstructions/reconstructor/refinement/__init__.py b/tests/unit/sampletones_core/reconstructions/reconstructor/refinement/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/tests/unit/sampletones_core/reconstructions/reconstructor/refinement/test_smoothing.py b/tests/unit/sampletones_core/reconstructions/reconstructor/refinement/test_smoothing.py
new file mode 100644
index 000000000..53ca141ae
--- /dev/null
+++ b/tests/unit/sampletones_core/reconstructions/reconstructor/refinement/test_smoothing.py
@@ -0,0 +1,109 @@
+from dataclasses import dataclass
+from typing import Final, List, Optional, Tuple
+
+import pytest
+
+from sampletones_core.reconstructions.reconstructor.refinement.smoothing import smoothed
+from tests.suite.base import BaseTestSuite
+from tests.suite.case import BaseRegularTestCase
+
+WINDOW: Final[int] = 4
+CHANGE_WEIGHT: Final[float] = 2.0
+
+
+def _smoothed(proposals: List[Optional[int]]) -> List[int]:
+ return smoothed(proposals, window=WINDOW, change_weight=CHANGE_WEIGHT)
+
+
+class TestWhatTheWalkSettlesOn(BaseTestSuite):
+ """The walk follows the readings while paying a toll on every change it makes."""
+
+ @dataclass(frozen=True, kw_only=True)
+ class TestCase(BaseRegularTestCase):
+ proposals: Tuple[Optional[int], ...]
+ expected: Tuple[int, ...]
+
+ test_cases: Tuple["TestWhatTheWalkSettlesOn.TestCase", ...] = (
+ TestCase(label="nothing read", proposals=(), expected=()),
+ TestCase(label="one reading", proposals=(5,), expected=(5,)),
+ TestCase(
+ label="a steady reading is kept",
+ proposals=(3, 3, 3, 3),
+ expected=(3, 3, 3, 3),
+ ),
+ TestCase(
+ label="a single stray reading is absorbed",
+ proposals=(3, 3, 6, 3, 3),
+ expected=(3, 3, 3, 3, 3),
+ ),
+ TestCase(
+ label="a stray reading worth two tolls is followed",
+ proposals=(3, 3, 12, 3, 3),
+ expected=(3, 3, 12, 3, 3),
+ ),
+ TestCase(
+ label="a reading that holds is followed",
+ proposals=(3, 3, 3, -8, -8, -8, -8),
+ expected=(3, 3, 3, -8, -8, -8, -8),
+ ),
+ TestCase(
+ label="frames that read nothing rest at no bend",
+ proposals=(None, None, None),
+ expected=(0, 0, 0),
+ ),
+ )
+
+ @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label)
+ def test_the_walk_settles_where_the_readings_point(
+ self,
+ test_case: "TestWhatTheWalkSettlesOn.TestCase",
+ ) -> None:
+ assert tuple(_smoothed(list(test_case.proposals))) == test_case.expected
+
+
+class TestTheTollOnChanging:
+ def test_a_walk_paying_nothing_to_change_follows_every_reading(self) -> None:
+ proposals: List[Optional[int]] = [3, 9, 3, 9]
+
+ assert smoothed(proposals, window=WINDOW, change_weight=0.0) == [3, 9, 3, 9]
+
+ def test_a_walk_that_cannot_afford_a_change_holds_one_bend(self) -> None:
+ proposals: List[Optional[int]] = [3, 9, 3, 9]
+
+ assert len(set(smoothed(proposals, window=WINDOW, change_weight=1000.0))) == 1
+
+ def test_an_excursion_is_absorbed_while_it_stays_within_two_tolls(self) -> None:
+ """Leaving a bend and returning costs two changes, which is what a blip has to beat."""
+ toll = int(2 * CHANGE_WEIGHT)
+ held: List[Optional[int]] = [3, 3, 3 + toll, 3, 3]
+ beyond: List[Optional[int]] = [3, 3, 3 + toll * 2, 3, 3]
+
+ assert _smoothed(held) == [3, 3, 3, 3, 3]
+ assert _smoothed(beyond)[2] != 3
+
+ def test_a_frame_that_read_nothing_costs_the_walk_nothing(self) -> None:
+ """A gap in the readings neither pulls the bend nor pays for holding it."""
+ proposals: List[Optional[int]] = [4, 4, None, None, 4, 4]
+
+ assert _smoothed(proposals) == [4, 4, 4, 4, 4, 4]
+
+ def test_every_frame_answers(self) -> None:
+ proposals: List[Optional[int]] = [1, None, 2, None, 3]
+
+ assert len(_smoothed(proposals)) == len(proposals)
+
+
+class TestTheStatesTheWalkConsiders:
+ def test_the_walk_settles_only_on_bends_its_neighborhood_read(self) -> None:
+ """The states are the readings, so a divider range spanning tens of steps stays small."""
+ proposals: List[Optional[int]] = [10, 20, 30]
+
+ assert set(_smoothed(proposals)) <= {0, 10, 20, 30}
+
+ def test_a_reading_beyond_the_window_is_out_of_reach(self) -> None:
+ """A frame settles on what stands near it, so a distant reading pulls it no further."""
+ proposals: List[Optional[int]] = [7] + [None] * (WINDOW * 3) + [7]
+
+ settled = smoothed(proposals, window=1, change_weight=CHANGE_WEIGHT)
+
+ assert settled[len(proposals) // 2] == 0
diff --git a/tests/unit/sampletones_core/reconstructions/reconstructor/selector/test_viterbi.py b/tests/unit/sampletones_core/reconstructions/reconstructor/selector/test_viterbi.py
deleted file mode 100644
index b6b968386..000000000
--- a/tests/unit/sampletones_core/reconstructions/reconstructor/selector/test_viterbi.py
+++ /dev/null
@@ -1,145 +0,0 @@
-from __future__ import annotations
-
-from typing import Any, Dict, List
-
-from sampletones_core.configs import Config
-from sampletones_core.constants.enums import GeneratorName
-from sampletones_core.fft import Window
-from sampletones_core.generators import GeneratorUnion
-from sampletones_core.instructions import PulseInstruction
-from sampletones_core.reconstructions.reconstructor.selector.base import ScoredCandidate
-from sampletones_core.reconstructions.reconstructor.selector.viterbi import (
- ViterbiSelector,
-)
-from sampletones_core.reconstructions.reconstructor.worker import ReconstructorWorker
-
-
-def _selector(
- config: Config,
- window: Window,
- generators: Dict[GeneratorName, GeneratorUnion],
- worker: ReconstructorWorker,
- **decoder_overrides: Any,
-) -> ViterbiSelector:
- decoder = config.generation.decoder.model_copy(update=decoder_overrides)
- updated_config = config.model_copy(update={"generation": config.generation.model_copy(update={"decoder": decoder})})
- return ViterbiSelector(
- updated_config,
- window,
- generators,
- worker.scorer,
- worker.candidate_provider,
- worker.phase_aligner,
- worker.feature_extractor,
- )
-
-
-def _state(instruction: PulseInstruction, cost: float) -> ScoredCandidate:
- return ScoredCandidate(instruction=instruction, cost=cost, approximation=None) # type: ignore[arg-type]
-
-
-def _per_frame_best(frames: List[List[ScoredCandidate]]) -> List[PulseInstruction]:
- return [min(frame, key=lambda state: state.cost).instruction for frame in frames]
-
-
-STEADY = PulseInstruction(on=True, pitch=60, volume=10, duty_cycle=0)
-JUMPED = PulseInstruction(on=True, pitch=72, volume=10, duty_cycle=0)
-
-
-def _flickering_frames() -> List[List[ScoredCandidate]]:
- return [
- [_state(STEADY, 0.00), _state(JUMPED, 0.05)],
- [_state(STEADY, 0.05), _state(JUMPED, 0.00)],
- [_state(STEADY, 0.00), _state(JUMPED, 0.05)],
- ]
-
-
-class TestViterbiContinuity:
- def test_continuity_holds_a_steady_note_where_per_frame_choice_flickers(
- self,
- config: Config,
- window: Window,
- generators: Dict[GeneratorName, GeneratorUnion],
- worker: ReconstructorWorker,
- ) -> None:
- selector = _selector(config, window, generators, worker, pitch_weight=1.0)
- frames = _flickering_frames()
-
- path = selector._decode(frames)
- chosen = [frames[position][path[position]].instruction for position in range(len(frames))]
-
- assert len({id(instruction) for instruction in _per_frame_best(frames)}) > 1
- assert len(set(chosen)) == 1
-
- def test_zero_transition_weights_reduce_to_per_frame_choice(
- self,
- config: Config,
- window: Window,
- generators: Dict[GeneratorName, GeneratorUnion],
- worker: ReconstructorWorker,
- ) -> None:
- selector = _selector(
- config,
- window,
- generators,
- worker,
- pitch_weight=0.0,
- volume_weight=0.0,
- timbre_weight=0.0,
- on_off_weight=0.0,
- )
- frames = _flickering_frames()
-
- path = selector._decode(frames)
- chosen = [frames[position][path[position]].instruction for position in range(len(frames))]
-
- assert chosen == _per_frame_best(frames)
-
-
-class TestViterbiTransitionCost:
- def test_identical_instruction_has_no_cost(
- self,
- config: Config,
- window: Window,
- generators: Dict[GeneratorName, GeneratorUnion],
- worker: ReconstructorWorker,
- ) -> None:
- selector = _selector(config, window, generators, worker)
- assert selector._transition_cost(STEADY, STEADY) == 0.0
-
- def test_larger_pitch_jump_costs_more(
- self,
- config: Config,
- window: Window,
- generators: Dict[GeneratorName, GeneratorUnion],
- worker: ReconstructorWorker,
- ) -> None:
- selector = _selector(config, window, generators, worker, pitch_weight=0.1)
- near = PulseInstruction(on=True, pitch=61, volume=10, duty_cycle=0)
- far = PulseInstruction(on=True, pitch=84, volume=10, duty_cycle=0)
- assert selector._transition_cost(STEADY, near) < selector._transition_cost(STEADY, far)
-
- def test_toggling_on_off_costs_the_on_off_weight(
- self,
- config: Config,
- window: Window,
- generators: Dict[GeneratorName, GeneratorUnion],
- worker: ReconstructorWorker,
- ) -> None:
- selector = _selector(config, window, generators, worker, on_off_weight=0.25)
- silence = PulseInstruction(on=False, pitch=60, volume=0, duty_cycle=0)
- assert selector._transition_cost(STEADY, silence) == 0.25
-
-
-class TestViterbiSelectIntegration:
- def test_select_is_deterministic(
- self,
- worker: ReconstructorWorker,
- fragmented_audio: Any,
- ) -> None:
- fragment_ids = fragmented_audio.fragments_ids
- first = worker(fragmented_audio, fragment_ids)
- second = worker(fragmented_audio, fragment_ids)
- for fragment_id in fragment_ids:
- for generator_name in first[fragment_id]:
- assert first[fragment_id][generator_name].instruction == second[fragment_id][generator_name].instruction
diff --git a/tests/unit/sampletones_core/reconstructions/reconstructor/stems/conftest.py b/tests/unit/sampletones_core/reconstructions/reconstructor/stems/conftest.py
new file mode 100644
index 000000000..ac166b879
--- /dev/null
+++ b/tests/unit/sampletones_core/reconstructions/reconstructor/stems/conftest.py
@@ -0,0 +1,70 @@
+from typing import Dict
+
+import pytest
+
+from sampletones_core.configs import Config
+from sampletones_core.constants.algorithm import SINGLE_STATE_LATTICE_WIDTH
+from sampletones_core.constants.enums import ChannelName
+from sampletones_core.fft import Fragment
+from sampletones_core.fft.features import FeatureExtractor
+from sampletones_core.generators import (
+ GeneratorUnion,
+ get_generator_by_instruction,
+ get_generators_by_channels,
+ get_remaining_generator_classes,
+)
+from sampletones_core.reconstructions.reconstructor.matching import FrameMatcher, ScoredCandidate
+from sampletones_core.reconstructions.reconstructor.stems.configs.config import StemsConfig
+from sampletones_core.reconstructions.reconstructor.worker import ReconstructorWorker
+
+
+@pytest.fixture(scope="module")
+def matcher(worker: ReconstructorWorker) -> FrameMatcher:
+ return worker.matcher
+
+
+@pytest.fixture(scope="module")
+def all_channels(config: Config) -> Dict[ChannelName, GeneratorUnion]:
+ return get_generators_by_channels(config, ChannelName.items())
+
+
+def greedy_baseline(
+ fragment: Fragment,
+ channels: Dict[ChannelName, GeneratorUnion],
+ matcher: FrameMatcher,
+ extractor: FeatureExtractor,
+) -> Dict[ChannelName, ScoredCandidate]:
+ """The classic per-frame reconstruction, restated here as the assignment's reference.
+
+ Takes the cheapest candidate across every channel still free, subtracts it from the
+ residual, and repeats until each channel is answered. A one-stem setup at full cap runs
+ exactly this order, which is what makes the two comparable frame by frame.
+ """
+ answers: Dict[ChannelName, ScoredCandidate] = {}
+ remaining_channels = dict(channels)
+ residual = fragment
+ while remaining_channels:
+ remaining_generator_classes = get_remaining_generator_classes(remaining_channels)
+ best = matcher.score_candidates(residual, remaining_generator_classes)[0]
+ generator = get_generator_by_instruction(best.instruction, remaining_generator_classes)
+ channel_name = ChannelName(generator.name)
+ answers[channel_name] = best
+ residual = extractor.subtract(residual, best.approximation)
+ del remaining_channels[channel_name]
+
+ return answers
+
+
+@pytest.fixture(scope="module")
+def lattice_width() -> int:
+ """The width a greedy decoder reads, which is what the equivalence baseline assumes."""
+ return SINGLE_STATE_LATTICE_WIDTH
+
+
+def shared_frames(fragment: Fragment, stems_config: StemsConfig) -> Dict[int, Fragment]:
+ """The frame every stem of ``stems_config`` contributes, one entry apiece.
+
+ Stems sounding alike leave hierarchy order, the channel cap and tie resolution as the only
+ things deciding the frame, which is what a case using this states.
+ """
+ return {entry.id: fragment for entry in stems_config.entries}
diff --git a/tests/unit/sampletones_core/reconstructions/reconstructor/stems/test_config.py b/tests/unit/sampletones_core/reconstructions/reconstructor/stems/test_config.py
new file mode 100644
index 000000000..48f837bfe
--- /dev/null
+++ b/tests/unit/sampletones_core/reconstructions/reconstructor/stems/test_config.py
@@ -0,0 +1,115 @@
+import pytest
+from pydantic import ValidationError
+
+from sampletones_core.constants.algorithm import DEFAULT_STEMS_CHANNEL_CAP
+from sampletones_core.constants.enums import ChannelName, HierarchyMode
+from sampletones_core.reconstructions.reconstructor.stems.configs.config import StemsConfig
+from sampletones_core.reconstructions.reconstructor.stems.configs.entry import StemEntry
+from sampletones_core.reconstructions.reconstructor.stems.configs.hierarchy import StemsHierarchy
+
+
+def _stems_config() -> StemsConfig:
+ return StemsConfig(
+ entries=[
+ StemEntry(id=0, channels=[ChannelName.PULSE1]),
+ StemEntry(id=1, channels=[ChannelName.NOISE]),
+ ],
+ hierarchy=StemsHierarchy(levels=[[0], [1]], mode=HierarchyMode.STRICT),
+ channel_cap=DEFAULT_STEMS_CHANNEL_CAP,
+ )
+
+
+class TestStemsConfig:
+ def test_defaults_to_an_empty_assignment(self) -> None:
+ stems = StemsConfig()
+ assert stems.entries == []
+ assert stems.hierarchy.levels == []
+ assert stems.hierarchy.mode == HierarchyMode.ROUND_ROBIN
+ assert stems.channel_cap == DEFAULT_STEMS_CHANNEL_CAP
+
+ def test_duplicate_entry_ids_raise(self) -> None:
+ with pytest.raises(ValidationError):
+ StemsConfig(
+ entries=[
+ StemEntry(id=0, channels=[ChannelName.PULSE1]),
+ StemEntry(id=0, channels=[ChannelName.NOISE]),
+ ],
+ hierarchy=StemsHierarchy(levels=[[0]]),
+ )
+
+ def test_channel_cap_below_one_raises(self) -> None:
+ with pytest.raises(ValidationError):
+ StemsConfig(channel_cap=0)
+
+ def test_fields(self) -> None:
+ stems = _stems_config()
+ assert [entry.id for entry in stems.entries] == [0, 1]
+ assert stems.hierarchy.levels == [[0], [1]]
+ assert stems.hierarchy.mode == HierarchyMode.STRICT
+ assert stems.channel_cap == DEFAULT_STEMS_CHANNEL_CAP
+
+
+class TestStemsConfigHierarchy:
+ """The hierarchy is the order the entries pick in, so it names each of them once."""
+
+ def test_a_duplicated_stem_raises(self) -> None:
+ with pytest.raises(ValidationError, match="exactly once"):
+ StemsConfig(
+ entries=[StemEntry(id=0, channels=[ChannelName.PULSE1])],
+ hierarchy=StemsHierarchy(levels=[[0], [0]]),
+ )
+
+ def test_a_stem_left_out_raises(self) -> None:
+ with pytest.raises(ValidationError, match="exactly once"):
+ StemsConfig(
+ entries=[
+ StemEntry(id=0, channels=[ChannelName.PULSE1]),
+ StemEntry(id=1, channels=[ChannelName.NOISE]),
+ ],
+ hierarchy=StemsHierarchy(levels=[[0]]),
+ )
+
+ def test_an_unknown_stem_raises(self) -> None:
+ with pytest.raises(ValidationError, match="exactly once"):
+ StemsConfig(
+ entries=[StemEntry(id=0, channels=[ChannelName.PULSE1])],
+ hierarchy=StemsHierarchy(levels=[[0], [5]]),
+ )
+
+
+class TestStemsConfigViews:
+ def test_entries_are_keyed_by_their_id(self) -> None:
+ stems = _stems_config()
+ assert set(stems.entries_by_id) == {0, 1}
+ assert stems.entries_by_id[1].channels == [ChannelName.NOISE]
+
+ def test_covered_channels_gather_every_entry(self) -> None:
+ stems = _stems_config()
+ assert stems.covered_channels == frozenset({ChannelName.PULSE1, ChannelName.NOISE})
+
+ def test_frame_budget_stops_at_the_covered_channels(self) -> None:
+ stems = _stems_config()
+ assert stems.frame_budget == len(stems.covered_channels)
+
+ def test_frame_budget_stops_at_the_cap(self) -> None:
+ stems = StemsConfig(
+ entries=[StemEntry(id=0, channels=[ChannelName.PULSE1, ChannelName.TRIANGLE, ChannelName.NOISE])],
+ hierarchy=StemsHierarchy(levels=[[0]]),
+ channel_cap=1,
+ )
+ assert stems.frame_budget == 1
+
+
+class TestSingleEntry:
+ def test_names_one_stem_over_every_channel(self) -> None:
+ channels = [ChannelName.PULSE1, ChannelName.TRIANGLE]
+ stems = StemsConfig.single_entry(channels)
+
+ assert [entry.channels for entry in stems.entries] == [channels]
+ assert stems.hierarchy.levels == [[0]]
+ assert stems.covered_channels == frozenset(channels)
+
+ def test_carries_the_cap_it_is_given(self) -> None:
+ stems = StemsConfig.single_entry([ChannelName.PULSE1, ChannelName.TRIANGLE], channel_cap=1)
+ assert stems.channel_cap == 1
+ assert stems.frame_budget == 1
diff --git a/tests/unit/sampletones_core/reconstructions/reconstructor/stems/test_equivalence.py b/tests/unit/sampletones_core/reconstructions/reconstructor/stems/test_equivalence.py
new file mode 100644
index 000000000..b581bdf15
--- /dev/null
+++ b/tests/unit/sampletones_core/reconstructions/reconstructor/stems/test_equivalence.py
@@ -0,0 +1,300 @@
+from typing import Dict, Final, Iterable, List, Tuple
+
+import numpy as np
+import pytest
+
+from sampletones_core.configs import Config
+from sampletones_core.constants.algorithm import SINGLE_STATE_LATTICE_WIDTH
+from sampletones_core.constants.enums import ChannelName, HierarchyMode
+from sampletones_core.fft import Fragment, Window
+from sampletones_core.fft.features import FeatureExtractor
+from sampletones_core.generators import GeneratorUnion
+from sampletones_core.library import InstructionLibraryData
+from sampletones_core.reconstructions.reconstructor.matching import FrameMatcher, ScoredCandidate
+from sampletones_core.reconstructions.reconstructor.stems.assignment.frame import assign_frame
+from sampletones_core.reconstructions.reconstructor.stems.configs.config import StemsConfig
+from sampletones_core.reconstructions.reconstructor.stems.configs.entry import StemEntry
+from sampletones_core.reconstructions.reconstructor.stems.configs.hierarchy import StemsHierarchy
+from sampletones_core.reconstructions.reconstructor.stems.models.choice import StemChoice
+from sampletones_core.reconstructions.reconstructor.stems.models.frame_assignment import StemFrameAssignment
+
+from .conftest import greedy_baseline, shared_frames
+
+RANDOM_SEEDS: Final[Tuple[int, ...]] = (11, 23, 47, 89, 131, 197)
+
+
+def _config(
+ entries: Dict[int, Iterable[ChannelName]],
+ levels: List[List[int]],
+ mode: HierarchyMode,
+ channel_cap: int,
+) -> StemsConfig:
+ return StemsConfig(
+ entries=[StemEntry(id=stem_id, channels=list(channels)) for stem_id, channels in entries.items()],
+ hierarchy=StemsHierarchy(levels=levels, mode=mode),
+ channel_cap=channel_cap,
+ )
+
+
+class TestSingleStemEquivalence:
+ def test_matches_the_greedy_baseline_exactly(
+ self,
+ synthetic_fragment: Fragment,
+ channels: Dict[ChannelName, GeneratorUnion],
+ matcher: FrameMatcher,
+ extractor: FeatureExtractor,
+ ) -> None:
+ stems_config = _config({0: channels}, [[0]], HierarchyMode.STRICT, len(channels))
+
+ assignment = assign_frame(
+ shared_frames(synthetic_fragment, stems_config),
+ stems_config,
+ channels,
+ matcher,
+ extractor,
+ SINGLE_STATE_LATTICE_WIDTH,
+ )
+ baseline = greedy_baseline(synthetic_fragment, channels, matcher, extractor)
+
+ assert len(assignment.choices) == len(channels)
+ assert len(baseline) == len(channels)
+ _assert_same_picks(assignment, baseline)
+
+ def test_matches_the_baseline_with_all_four_channels(
+ self,
+ synthetic_fragment: Fragment,
+ all_channels: Dict[ChannelName, GeneratorUnion],
+ matcher: FrameMatcher,
+ extractor: FeatureExtractor,
+ ) -> None:
+ stems_config = _config({0: all_channels}, [[0]], HierarchyMode.STRICT, len(all_channels))
+
+ assignment = assign_frame(
+ shared_frames(synthetic_fragment, stems_config),
+ stems_config,
+ all_channels,
+ matcher,
+ extractor,
+ SINGLE_STATE_LATTICE_WIDTH,
+ )
+ baseline = greedy_baseline(synthetic_fragment, all_channels, matcher, extractor)
+
+ assert len(assignment.choices) == len(all_channels)
+ assert len(baseline) == len(all_channels)
+ _assert_same_picks(assignment, baseline)
+
+
+class TestLatticeWidthLeavesOwnership:
+ """A wider lattice grows what the decoder may choose from, and the picks stay put."""
+
+ def test_ownership_and_picks_hold_across_widths(
+ self,
+ synthetic_fragment: Fragment,
+ all_channels: Dict[ChannelName, GeneratorUnion],
+ matcher: FrameMatcher,
+ extractor: FeatureExtractor,
+ ) -> None:
+ stems_config = _config(
+ {0: [ChannelName.PULSE1, ChannelName.TRIANGLE], 1: [ChannelName.PULSE2, ChannelName.NOISE]},
+ [[0], [1]],
+ HierarchyMode.STRICT,
+ 2,
+ )
+
+ narrow = assign_frame(
+ shared_frames(synthetic_fragment, stems_config),
+ stems_config,
+ all_channels,
+ matcher,
+ extractor,
+ SINGLE_STATE_LATTICE_WIDTH,
+ )
+ wide = assign_frame(
+ shared_frames(synthetic_fragment, stems_config),
+ stems_config,
+ all_channels,
+ matcher,
+ extractor,
+ matcher.top_k,
+ )
+
+ assert _choice_keys(narrow.choices) == _choice_keys(wide.choices)
+ assert narrow.resting == wide.resting
+ for narrow_choice, wide_choice in zip(narrow.choices, wide.choices):
+ assert narrow_choice.instruction == wide_choice.instruction
+ _assert_same_fragment(narrow_choice.approximation, wide_choice.approximation)
+ assert len(wide_choice.column) >= len(narrow_choice.column)
+
+
+class TestStrictDisjointStems:
+ def test_each_stem_answers_its_own_recording(
+ self,
+ audible_fragments: List[Fragment],
+ channels: Dict[ChannelName, GeneratorUnion],
+ matcher: FrameMatcher,
+ extractor: FeatureExtractor,
+ ) -> None:
+ """Two stems sounding differently each answer their own frame over their own channels.
+
+ A stem's picks are the greedy reconstruction of the sound it contributes, so the channels
+ it holds carry that recording and the other stem takes no part in them.
+ """
+ assert len(audible_fragments) >= 2
+ first_fragment, second_fragment = audible_fragments[0], audible_fragments[-1]
+ subset_pulse_triangle = {
+ ChannelName.PULSE1: channels[ChannelName.PULSE1],
+ ChannelName.TRIANGLE: channels[ChannelName.TRIANGLE],
+ }
+ subset_noise = {ChannelName.NOISE: channels[ChannelName.NOISE]}
+
+ expected = dict(greedy_baseline(first_fragment, subset_pulse_triangle, matcher, extractor))
+ expected.update(greedy_baseline(second_fragment, subset_noise, matcher, extractor))
+
+ stems_config = _config(
+ {0: subset_pulse_triangle, 1: subset_noise},
+ [[0], [1]],
+ HierarchyMode.STRICT,
+ len(channels),
+ )
+
+ assignment = assign_frame(
+ {0: first_fragment, 1: second_fragment},
+ stems_config,
+ channels,
+ matcher,
+ extractor,
+ SINGLE_STATE_LATTICE_WIDTH,
+ )
+
+ assert len(assignment.choices) == len(channels)
+ _assert_same_picks(assignment, expected)
+
+
+class TestRandomizedDifferential:
+ @pytest.mark.parametrize("random_seed", RANDOM_SEEDS)
+ def test_invariants_and_determinism(
+ self,
+ random_seed: int,
+ config: Config,
+ window: Window,
+ extractor: FeatureExtractor,
+ library_data: InstructionLibraryData,
+ all_channels: Dict[ChannelName, GeneratorUnion],
+ matcher: FrameMatcher,
+ ) -> None:
+ rng = np.random.default_rng(random_seed)
+ fragment = _random_target_fragment(rng, config, window, extractor, library_data)
+ stems_config = _random_setup(rng, tuple(all_channels))
+
+ assignment = assign_frame(
+ shared_frames(fragment, stems_config),
+ stems_config,
+ all_channels,
+ matcher,
+ extractor,
+ SINGLE_STATE_LATTICE_WIDTH,
+ )
+ repeat = assign_frame(
+ shared_frames(fragment, stems_config),
+ stems_config,
+ all_channels,
+ matcher,
+ extractor,
+ SINGLE_STATE_LATTICE_WIDTH,
+ )
+
+ assert _choice_keys(assignment.choices) == _choice_keys(repeat.choices)
+ assert assignment.resting == repeat.resting
+
+ channels_assigned = [choice.channel_name for choice in assignment.choices]
+ assert len(channels_assigned) == len(set(channels_assigned))
+ assert set(channels_assigned) <= set(all_channels)
+ assert set(channels_assigned) | set(assignment.resting) == stems_config.covered_channels
+
+ counts: Dict[int, int] = {}
+ for choice in assignment.choices:
+ counts[choice.stem_id] = counts.get(choice.stem_id, 0) + 1
+ assert choice.channel_name in stems_config.entries_by_id[choice.stem_id].channel_set
+ assert all(count <= stems_config.channel_cap for count in counts.values())
+
+ if stems_config.hierarchy.mode == HierarchyMode.STRICT:
+ _assert_strict_ordering(assignment, stems_config.hierarchy)
+
+
+def _assert_same_picks(
+ assignment: StemFrameAssignment,
+ baseline: Dict[ChannelName, ScoredCandidate],
+) -> None:
+ assert [choice.channel_name for choice in assignment.choices] == list(baseline.keys())
+ assert set(assignment.by_channel) == set(baseline)
+
+ for channel_name, candidate in baseline.items():
+ choice = assignment.by_channel[channel_name]
+ assert choice.instruction == candidate.instruction
+ _assert_same_fragment(choice.approximation, candidate.approximation)
+
+
+def _assert_same_fragment(left: Fragment, right: Fragment) -> None:
+ np.testing.assert_array_equal(np.asarray(left.audio), np.asarray(right.audio))
+ np.testing.assert_array_equal(
+ np.asarray(left.windowed_audio),
+ np.asarray(right.windowed_audio),
+ )
+ np.testing.assert_array_equal(
+ np.asarray(left.feature.values),
+ np.asarray(right.feature.values),
+ )
+
+
+def _assert_strict_ordering(
+ assignment: StemFrameAssignment,
+ hierarchy: StemsHierarchy,
+) -> None:
+ first_positions = [
+ index for index, choice in enumerate(assignment.choices) if choice.stem_id in hierarchy.levels[0]
+ ]
+ second_positions = [
+ index for index, choice in enumerate(assignment.choices) if choice.stem_id in hierarchy.levels[1]
+ ]
+ if first_positions and second_positions:
+ assert max(first_positions) < min(second_positions)
+
+
+def _choice_keys(choices: Tuple[StemChoice, ...]) -> Tuple[Tuple[int, ChannelName], ...]:
+ return tuple((choice.stem_id, choice.channel_name) for choice in choices)
+
+
+def _random_setup(
+ rng: np.random.Generator,
+ channel_names: Tuple[ChannelName, ...],
+) -> StemsConfig:
+ shuffled = list(channel_names)
+ rng.shuffle(shuffled)
+ split = int(rng.integers(1, len(shuffled)))
+
+ mode = HierarchyMode.ROUND_ROBIN if bool(rng.integers(2)) else HierarchyMode.STRICT
+ return _config(
+ {0: shuffled[:split], 1: shuffled[split:]},
+ [[0], [1]],
+ mode,
+ int(rng.integers(1, len(channel_names) + 1)),
+ )
+
+
+def _random_target_fragment(
+ rng: np.random.Generator,
+ config: Config,
+ window: Window,
+ extractor: FeatureExtractor,
+ library_data: InstructionLibraryData,
+) -> Fragment:
+ instructions = [instruction for instruction in library_data.keys() if library_data[instruction].length > 0]
+ audio = np.zeros(window.frame_length, dtype=np.float64)
+ for _ in range(int(rng.integers(1, 5))):
+ instruction = instructions[int(rng.integers(0, len(instructions)))]
+ library_fragment = library_data[instruction]
+ shift = int(rng.integers(0, library_fragment.length))
+ contribution = library_fragment.get_fragment(shift, config, window)
+ audio += np.asarray(contribution.audio) * float(rng.uniform(0.2, 1.0))
+
+ return extractor.extract(audio)[0]
diff --git a/tests/unit/sampletones_core/reconstructions/reconstructor/stems/test_frame.py b/tests/unit/sampletones_core/reconstructions/reconstructor/stems/test_frame.py
new file mode 100644
index 000000000..b73d7339b
--- /dev/null
+++ b/tests/unit/sampletones_core/reconstructions/reconstructor/stems/test_frame.py
@@ -0,0 +1,463 @@
+from typing import Dict, Final, List, Sequence, Tuple
+
+import numpy as np
+import pytest
+
+from sampletones_core.constants.algorithm import SINGLE_STATE_LATTICE_WIDTH
+from sampletones_core.constants.enums import ChannelName, HierarchyMode
+from sampletones_core.fft import Fragment
+from sampletones_core.fft.features import FeatureExtractor
+from sampletones_core.generators import GeneratorUnion
+from sampletones_core.reconstructions.reconstructor.matching import FrameMatcher
+from sampletones_core.reconstructions.reconstructor.stems.assignment.frame import assign_frame
+from sampletones_core.reconstructions.reconstructor.stems.configs.config import StemsConfig
+from sampletones_core.reconstructions.reconstructor.stems.configs.entry import StemEntry
+from sampletones_core.reconstructions.reconstructor.stems.configs.hierarchy import StemsHierarchy
+from sampletones_core.reconstructions.reconstructor.stems.models.choice import StemChoice
+from sampletones_core.reconstructions.reconstructor.stems.models.frame_assignment import StemFrameAssignment
+
+from .conftest import shared_frames
+
+DEFAULT_CHANNELS: List[ChannelName] = [ChannelName.PULSE1, ChannelName.TRIANGLE, ChannelName.NOISE]
+LOUDER_STEM_SCALE: Final[float] = 4.0
+
+
+def _config(
+ entries: Dict[int, List[ChannelName]],
+ levels: List[List[int]],
+ mode: HierarchyMode,
+ channel_cap: int,
+) -> StemsConfig:
+ return StemsConfig(
+ entries=[StemEntry(id=stem_id, channels=channels) for stem_id, channels in entries.items()],
+ hierarchy=StemsHierarchy(levels=levels, mode=mode),
+ channel_cap=channel_cap,
+ )
+
+
+def _assign(
+ fragment: Fragment,
+ stems_config: StemsConfig,
+ channels: Dict[ChannelName, GeneratorUnion],
+ matcher: FrameMatcher,
+ extractor: FeatureExtractor,
+ lattice_width: int = SINGLE_STATE_LATTICE_WIDTH,
+) -> StemFrameAssignment:
+ return assign_frame(
+ shared_frames(fragment, stems_config),
+ stems_config,
+ channels,
+ matcher,
+ extractor,
+ lattice_width,
+ )
+
+
+class TestAssignFrameValidation:
+ def test_channel_outside_enabled_channels_raises(
+ self,
+ synthetic_fragment: Fragment,
+ channels: Dict[ChannelName, GeneratorUnion],
+ matcher: FrameMatcher,
+ extractor: FeatureExtractor,
+ ) -> None:
+ stems_config = _config({0: [ChannelName.PULSE2]}, [[0]], HierarchyMode.STRICT, 1)
+ with pytest.raises(ValueError, match="configuration lacks"):
+ _assign(synthetic_fragment, stems_config, channels, matcher, extractor)
+
+
+class TestFrameCompleteness:
+ """Every covered channel leaves the frame either picked or resting, never neither."""
+
+ def test_a_capped_frame_rests_the_channels_no_stem_took(
+ self,
+ synthetic_fragment: Fragment,
+ channels: Dict[ChannelName, GeneratorUnion],
+ matcher: FrameMatcher,
+ extractor: FeatureExtractor,
+ ) -> None:
+ stems_config = _config({0: DEFAULT_CHANNELS}, [[0]], HierarchyMode.STRICT, 1)
+
+ assignment = _assign(synthetic_fragment, stems_config, channels, matcher, extractor)
+
+ assert len(assignment.choices) == 1
+ assert set(assignment.by_channel) | set(assignment.resting) == stems_config.covered_channels
+ assert set(assignment.by_channel).isdisjoint(assignment.resting)
+
+ def test_a_full_frame_rests_nothing(
+ self,
+ synthetic_fragment: Fragment,
+ channels: Dict[ChannelName, GeneratorUnion],
+ matcher: FrameMatcher,
+ extractor: FeatureExtractor,
+ ) -> None:
+ stems_config = _config({0: DEFAULT_CHANNELS}, [[0]], HierarchyMode.STRICT, len(DEFAULT_CHANNELS))
+
+ assignment = _assign(synthetic_fragment, stems_config, channels, matcher, extractor)
+
+ assert assignment.resting == ()
+ assert set(assignment.by_channel) == stems_config.covered_channels
+
+ def test_a_channel_no_stem_may_occupy_stays_out_of_the_frame(
+ self,
+ synthetic_fragment: Fragment,
+ channels: Dict[ChannelName, GeneratorUnion],
+ matcher: FrameMatcher,
+ extractor: FeatureExtractor,
+ ) -> None:
+ stems_config = _config({0: [ChannelName.PULSE1]}, [[0]], HierarchyMode.STRICT, 1)
+
+ assignment = _assign(synthetic_fragment, stems_config, channels, matcher, extractor)
+
+ assert set(assignment.by_channel) == {ChannelName.PULSE1}
+ assert assignment.resting == ()
+
+
+class TestSoundingStems:
+ """A stem takes a channel where its own recording sounds, and stands aside where it does not."""
+
+ def test_a_stem_sounding_nothing_leaves_the_channel_to_one_that_does(
+ self,
+ synthetic_fragment: Fragment,
+ channels: Dict[ChannelName, GeneratorUnion],
+ matcher: FrameMatcher,
+ extractor: FeatureExtractor,
+ ) -> None:
+ """A silent stem picking first passes, so the channel reaches the stem behind it.
+
+ This is what keeps a recording quiet through a passage from sounding that passage on the
+ channels it holds elsewhere.
+ """
+ stems_config = _config(
+ {0: [ChannelName.PULSE1], 1: [ChannelName.PULSE1]},
+ [[0], [1]],
+ HierarchyMode.STRICT,
+ 1,
+ )
+
+ assignment = assign_frame(
+ {0: synthetic_fragment * 0.0, 1: synthetic_fragment},
+ stems_config,
+ channels,
+ matcher,
+ extractor,
+ SINGLE_STATE_LATTICE_WIDTH,
+ )
+
+ assert [choice.stem_id for choice in assignment.choices] == [1]
+ assert assignment.resting == ()
+
+ def test_a_frame_no_stem_sounds_in_rests_every_channel(
+ self,
+ synthetic_fragment: Fragment,
+ channels: Dict[ChannelName, GeneratorUnion],
+ matcher: FrameMatcher,
+ extractor: FeatureExtractor,
+ ) -> None:
+ """Every covered channel still answers the frame, each holding its null instruction."""
+ stems_config = _config({0: DEFAULT_CHANNELS}, [[0]], HierarchyMode.STRICT, len(DEFAULT_CHANNELS))
+ silent = synthetic_fragment * 0.0
+
+ assignment = assign_frame(
+ {0: silent},
+ stems_config,
+ channels,
+ matcher,
+ extractor,
+ SINGLE_STATE_LATTICE_WIDTH,
+ )
+
+ assert assignment.choices == ()
+ assert set(assignment.resting) == set(DEFAULT_CHANNELS)
+ for rest in assignment.rests:
+ assert not rest.column[0].instruction.on
+
+
+class TestBidsWithinALevel:
+ """Two stems sharing a level compete for a channel by what each still has to render."""
+
+ def _pulse_cost(
+ self,
+ fragment: Fragment,
+ channels: Dict[ChannelName, GeneratorUnion],
+ matcher: FrameMatcher,
+ ) -> float:
+ pulse = channels[ChannelName.PULSE1]
+ return matcher.score_candidates(fragment, {pulse.class_name(): pulse})[0].cost
+
+ def test_the_louder_stem_takes_the_channel(
+ self,
+ synthetic_fragment: Fragment,
+ channels: Dict[ChannelName, GeneratorUnion],
+ matcher: FrameMatcher,
+ extractor: FeatureExtractor,
+ ) -> None:
+ """The louder recording wins the channel though the quieter one is the closer match.
+
+ A cost is a fraction of its own recording's energy, so the quiet stem scores the better
+ cost; weighting that cost by the energy behind it is what sends the channel where more
+ sound is waiting. The winner follows the recordings, not the place a stem holds.
+ """
+ quiet = synthetic_fragment
+ loud = synthetic_fragment * LOUDER_STEM_SCALE
+ assert self._pulse_cost(quiet, channels, matcher) < self._pulse_cost(loud, channels, matcher)
+
+ stems_config = _config(
+ {0: [ChannelName.PULSE1], 1: [ChannelName.PULSE1]},
+ [[0, 1]],
+ HierarchyMode.STRICT,
+ 1,
+ )
+
+ for loud_stem_id in (0, 1):
+ assignment = assign_frame(
+ {loud_stem_id: loud, 1 - loud_stem_id: quiet},
+ stems_config,
+ channels,
+ matcher,
+ extractor,
+ SINGLE_STATE_LATTICE_WIDTH,
+ )
+
+ assert [choice.stem_id for choice in assignment.choices] == [loud_stem_id]
+
+ def test_a_level_of_its_own_takes_the_channel_first(
+ self,
+ synthetic_fragment: Fragment,
+ channels: Dict[ChannelName, GeneratorUnion],
+ matcher: FrameMatcher,
+ extractor: FeatureExtractor,
+ ) -> None:
+ """Levels pick in the order they are listed, so the first level takes the channel.
+
+ Bids settle a level's own competition; precedence between levels stays the hierarchy's,
+ which is what a reader arranges the levels to say.
+ """
+ stems_config = _config(
+ {0: [ChannelName.PULSE1], 1: [ChannelName.PULSE1]},
+ [[0], [1]],
+ HierarchyMode.STRICT,
+ 1,
+ )
+
+ assignment = assign_frame(
+ {0: synthetic_fragment, 1: synthetic_fragment * LOUDER_STEM_SCALE},
+ stems_config,
+ channels,
+ matcher,
+ extractor,
+ SINGLE_STATE_LATTICE_WIDTH,
+ )
+
+ assert [choice.stem_id for choice in assignment.choices] == [0]
+
+
+class TestColumns:
+ """Every channel leaves the frame with the alternatives the decoder reads."""
+
+ def test_a_narrow_lattice_answers_each_pick_with_itself(
+ self,
+ synthetic_fragment: Fragment,
+ channels: Dict[ChannelName, GeneratorUnion],
+ matcher: FrameMatcher,
+ extractor: FeatureExtractor,
+ ) -> None:
+ stems_config = _config({0: DEFAULT_CHANNELS}, [[0]], HierarchyMode.STRICT, len(DEFAULT_CHANNELS))
+
+ assignment = _assign(synthetic_fragment, stems_config, channels, matcher, extractor)
+
+ for choice in assignment.choices:
+ assert len(choice.column) == SINGLE_STATE_LATTICE_WIDTH
+ assert choice.column[0].instruction == choice.instruction
+
+ def test_a_wide_lattice_holds_the_pick_among_its_alternatives(
+ self,
+ synthetic_fragment: Fragment,
+ channels: Dict[ChannelName, GeneratorUnion],
+ matcher: FrameMatcher,
+ extractor: FeatureExtractor,
+ ) -> None:
+ """A wider column reaches the winning channel's own candidates, the pick among them."""
+ stems_config = _config({0: DEFAULT_CHANNELS}, [[0]], HierarchyMode.STRICT, len(DEFAULT_CHANNELS))
+ width = matcher.top_k
+
+ assignment = _assign(synthetic_fragment, stems_config, channels, matcher, extractor, width)
+
+ for choice in assignment.choices:
+ instructions = [candidate.instruction for candidate in choice.column]
+ assert 0 < len(choice.column) <= width
+ assert choice.instruction in instructions
+ assert len(set(instructions)) == len(instructions)
+
+ def test_a_wide_lattice_offers_one_channel_its_own_candidates(
+ self,
+ synthetic_fragment: Fragment,
+ channels: Dict[ChannelName, GeneratorUnion],
+ matcher: FrameMatcher,
+ extractor: FeatureExtractor,
+ ) -> None:
+ stems_config = _config({0: [ChannelName.PULSE1]}, [[0]], HierarchyMode.STRICT, 1)
+
+ assignment = _assign(synthetic_fragment, stems_config, channels, matcher, extractor, matcher.top_k)
+
+ column = assignment.by_channel[ChannelName.PULSE1].column
+ pulse_class = channels[ChannelName.PULSE1].class_name()
+ expected = matcher.score_candidates(synthetic_fragment, {pulse_class: channels[ChannelName.PULSE1]})
+ assert [candidate.instruction for candidate in column] == [
+ candidate.instruction for candidate in expected[: matcher.top_k]
+ ]
+
+ def test_a_resting_channel_holds_its_null_instruction_alone(
+ self,
+ synthetic_fragment: Fragment,
+ channels: Dict[ChannelName, GeneratorUnion],
+ matcher: FrameMatcher,
+ extractor: FeatureExtractor,
+ ) -> None:
+ """A rest is a column of one, so a channel no stem took still answers its frame."""
+ stems_config = _config({0: DEFAULT_CHANNELS}, [[0]], HierarchyMode.STRICT, 1)
+
+ assignment = _assign(synthetic_fragment, stems_config, channels, matcher, extractor, matcher.top_k)
+
+ assert assignment.rests
+ for rest in assignment.rests:
+ assert len(rest.column) == SINGLE_STATE_LATTICE_WIDTH
+ candidate = rest.column[0]
+ assert candidate.instruction == channels[rest.channel_name].get_instruction_type().null_instruction()
+ assert not np.any(np.asarray(candidate.approximation.audio))
+
+
+class TestChannelCap:
+ def test_strict_mode_respects_the_cap(
+ self,
+ synthetic_fragment: Fragment,
+ channels: Dict[ChannelName, GeneratorUnion],
+ matcher: FrameMatcher,
+ extractor: FeatureExtractor,
+ ) -> None:
+ for cap, expected_count in ((1, 1), (2, 2), (5, 3)):
+ stems_config = _config({0: DEFAULT_CHANNELS}, [[0]], HierarchyMode.STRICT, cap)
+ assignment = _assign(synthetic_fragment, stems_config, channels, matcher, extractor)
+ assert len(assignment.choices) == expected_count
+ assert {choice.stem_id for choice in assignment.choices} == {0}
+ assert len(assignment.resting) == len(DEFAULT_CHANNELS) - expected_count
+
+ def test_round_robin_mode_respects_the_cap(
+ self,
+ synthetic_fragment: Fragment,
+ channels: Dict[ChannelName, GeneratorUnion],
+ matcher: FrameMatcher,
+ extractor: FeatureExtractor,
+ ) -> None:
+ stems_config = _config({0: DEFAULT_CHANNELS}, [[0]], HierarchyMode.ROUND_ROBIN, 2)
+
+ assignment = _assign(synthetic_fragment, stems_config, channels, matcher, extractor)
+
+ assert len(assignment.choices) == 2
+ assert len(assignment.resting) == 1
+
+
+class TestTieBreakDeterminism:
+ def test_equal_cost_choices_go_to_the_first_stem_in_level_order(
+ self,
+ synthetic_fragment: Fragment,
+ channels: Dict[ChannelName, GeneratorUnion],
+ matcher: FrameMatcher,
+ extractor: FeatureExtractor,
+ ) -> None:
+ entries = {0: [ChannelName.PULSE1], 1: [ChannelName.PULSE1]}
+
+ first = _assign(
+ synthetic_fragment,
+ _config(entries, [[0, 1]], HierarchyMode.STRICT, 1),
+ channels,
+ matcher,
+ extractor,
+ )
+ assert [(choice.stem_id, choice.channel_name) for choice in first.choices] == [(0, ChannelName.PULSE1)]
+
+ swapped = _assign(
+ synthetic_fragment,
+ _config(entries, [[1, 0]], HierarchyMode.STRICT, 1),
+ channels,
+ matcher,
+ extractor,
+ )
+ assert [(choice.stem_id, choice.channel_name) for choice in swapped.choices] == [(1, ChannelName.PULSE1)]
+
+ def test_repeated_runs_give_identical_choices(
+ self,
+ synthetic_fragment: Fragment,
+ channels: Dict[ChannelName, GeneratorUnion],
+ matcher: FrameMatcher,
+ extractor: FeatureExtractor,
+ ) -> None:
+ stems_config = _config(
+ {0: [ChannelName.PULSE1, ChannelName.TRIANGLE], 1: [ChannelName.NOISE]},
+ [[0], [1]],
+ HierarchyMode.STRICT,
+ 2,
+ )
+
+ first = _assign(synthetic_fragment, stems_config, channels, matcher, extractor)
+ second = _assign(synthetic_fragment, stems_config, channels, matcher, extractor)
+
+ assert _choice_keys(first.choices) == _choice_keys(second.choices)
+ assert first.resting == second.resting
+
+
+class TestHierarchyOrdering:
+ def test_strict_mode_exhausts_the_first_level_before_the_next(
+ self,
+ synthetic_fragment: Fragment,
+ channels: Dict[ChannelName, GeneratorUnion],
+ matcher: FrameMatcher,
+ extractor: FeatureExtractor,
+ ) -> None:
+ stems_config = _config(
+ {0: [ChannelName.PULSE1, ChannelName.TRIANGLE], 1: [ChannelName.NOISE]},
+ [[0], [1]],
+ HierarchyMode.STRICT,
+ 2,
+ )
+
+ assignment = _assign(synthetic_fragment, stems_config, channels, matcher, extractor)
+
+ assert [choice.stem_id for choice in assignment.choices] == [0, 0, 1]
+ assert {choice.channel_name for choice in assignment.choices[:2]} == {
+ ChannelName.PULSE1,
+ ChannelName.TRIANGLE,
+ }
+ assert assignment.choices[2].channel_name == ChannelName.NOISE
+
+ def test_round_robin_mode_alternates_levels_each_round(
+ self,
+ synthetic_fragment: Fragment,
+ channels: Dict[ChannelName, GeneratorUnion],
+ matcher: FrameMatcher,
+ extractor: FeatureExtractor,
+ ) -> None:
+ stems_config = _config(
+ {0: [ChannelName.PULSE1, ChannelName.TRIANGLE], 1: [ChannelName.NOISE]},
+ [[0], [1]],
+ HierarchyMode.ROUND_ROBIN,
+ 2,
+ )
+
+ assignment = _assign(synthetic_fragment, stems_config, channels, matcher, extractor)
+
+ assert [choice.stem_id for choice in assignment.choices] == [0, 1, 0]
+ assert assignment.choices[1].channel_name == ChannelName.NOISE
+ assert {assignment.choices[0].channel_name, assignment.choices[2].channel_name} == {
+ ChannelName.PULSE1,
+ ChannelName.TRIANGLE,
+ }
+ assert assignment.by_channel.keys() == {
+ ChannelName.PULSE1,
+ ChannelName.TRIANGLE,
+ ChannelName.NOISE,
+ }
+
+
+def _choice_keys(choices: Sequence[StemChoice]) -> Tuple[Tuple[int, ChannelName], ...]:
+ return tuple((choice.stem_id, choice.channel_name) for choice in choices)
diff --git a/tests/unit/sampletones_core/reconstructions/reconstructor/stems/test_models.py b/tests/unit/sampletones_core/reconstructions/reconstructor/stems/test_models.py
new file mode 100644
index 000000000..018434847
--- /dev/null
+++ b/tests/unit/sampletones_core/reconstructions/reconstructor/stems/test_models.py
@@ -0,0 +1,145 @@
+from typing import Final, Tuple
+
+import numpy as np
+
+from sampletones_core.configs import Config
+from sampletones_core.constants.algorithm import RESTING_FRAME_COST, RESTING_STEM_ID
+from sampletones_core.constants.enums import ChannelName, HierarchyMode
+from sampletones_core.fft import Fragment
+from sampletones_core.instructions import (
+ InstructionUnion,
+ NoiseInstruction,
+ PulseInstruction,
+ TriangleInstruction,
+)
+from sampletones_core.reconstructions.reconstructor.matching import ScoredCandidate
+from sampletones_core.reconstructions.reconstructor.stems.assignment.track import TrackAssignment
+from sampletones_core.reconstructions.reconstructor.stems.models.choice import StemChoice
+from sampletones_core.reconstructions.reconstructor.stems.models.frame_assignment import StemFrameAssignment
+from sampletones_core.reconstructions.reconstructor.stems.models.rest import StemRest
+from sampletones_core.structures.histogram import Histogram
+
+FRAME_COST: Final[float] = 0.5
+
+
+def _fragment(config: Config) -> Fragment:
+ audio = np.zeros(config.frame_length, dtype=np.float32)
+ return Fragment(
+ audio=audio,
+ feature=Histogram(edges=np.array([0.0, 1.0], dtype=np.float32), values=np.zeros(1, dtype=np.float32)),
+ windowed_audio=audio,
+ config=config,
+ )
+
+
+def _choices(config: Config) -> Tuple[StemChoice, StemChoice]:
+ fragment = _fragment(config)
+ return (
+ _choice(0, ChannelName.PULSE1, PulseInstruction.default_instruction(), fragment),
+ _choice(1, ChannelName.TRIANGLE, TriangleInstruction.default_instruction(), fragment),
+ )
+
+
+def _choice(
+ stem_id: int,
+ channel_name: ChannelName,
+ instruction: InstructionUnion,
+ fragment: Fragment,
+) -> StemChoice:
+ return StemChoice(
+ stem_id=stem_id,
+ channel_name=channel_name,
+ instruction=instruction,
+ approximation=fragment,
+ cost=FRAME_COST,
+ column=(ScoredCandidate(instruction=instruction, cost=FRAME_COST, approximation=fragment),),
+ )
+
+
+def _rest(config: Config) -> StemRest:
+ fragment = _fragment(config)
+ instruction = NoiseInstruction.null_instruction()
+ return StemRest(
+ channel_name=ChannelName.NOISE,
+ column=(ScoredCandidate(instruction=instruction, cost=RESTING_FRAME_COST, approximation=fragment),),
+ )
+
+
+class TestStemFrameAssignment:
+ def test_by_channel_names_the_stem_holding_each_channel(self) -> None:
+ first, second = _choices(Config())
+
+ assignment = StemFrameAssignment(choices=(first, second), rests=())
+
+ assert assignment.by_channel == {
+ ChannelName.PULSE1: first,
+ ChannelName.TRIANGLE: second,
+ }
+
+ def test_picked_and_resting_channels_stay_apart(self) -> None:
+ config = Config()
+ first, _ = _choices(config)
+
+ assignment = StemFrameAssignment(choices=(first,), rests=(_rest(config),))
+
+ assert assignment.resting == (ChannelName.NOISE,)
+ assert set(assignment.by_channel).isdisjoint(assignment.resting)
+
+
+class TestTrackAssignment:
+ def test_a_frame_reaches_every_channel_it_answers(self) -> None:
+ config = Config()
+ first, second = _choices(config)
+ rest = _rest(config)
+
+ track = TrackAssignment([ChannelName.PULSE1, ChannelName.TRIANGLE, ChannelName.NOISE])
+ track.add(StemFrameAssignment(choices=(first, second), rests=(rest,)))
+
+ assert track.stem_ids == {
+ ChannelName.PULSE1: [first.stem_id],
+ ChannelName.TRIANGLE: [second.stem_id],
+ ChannelName.NOISE: [RESTING_STEM_ID],
+ }
+ assert track.lattices[ChannelName.PULSE1] == [first.column]
+ assert track.lattices[ChannelName.NOISE] == [rest.column]
+
+ def test_a_channel_resting_throughout_is_named_resting(self) -> None:
+ config = Config()
+ first, _ = _choices(config)
+ rest = _rest(config)
+
+ track = TrackAssignment([ChannelName.PULSE1, ChannelName.NOISE])
+ for _ in range(3):
+ track.add(StemFrameAssignment(choices=(first,), rests=(rest,)))
+
+ assert track.resting_channels == [ChannelName.NOISE]
+
+ def test_a_channel_that_sounds_once_keeps_its_place(self) -> None:
+ config = Config()
+ first, _ = _choices(config)
+ rest = _rest(config)
+ noise_choice = _choice(0, ChannelName.NOISE, NoiseInstruction.default_instruction(), _fragment(config))
+
+ track = TrackAssignment([ChannelName.PULSE1, ChannelName.NOISE])
+ track.add(StemFrameAssignment(choices=(first,), rests=(rest,)))
+ track.add(StemFrameAssignment(choices=(first, noise_choice), rests=()))
+
+ assert track.resting_channels == []
+ assert track.stem_ids[ChannelName.NOISE] == [RESTING_STEM_ID, 0]
+
+ def test_dropping_a_channel_releases_both_records(self) -> None:
+ config = Config()
+ first, _ = _choices(config)
+ rest = _rest(config)
+
+ track = TrackAssignment([ChannelName.PULSE1, ChannelName.NOISE])
+ track.add(StemFrameAssignment(choices=(first,), rests=(rest,)))
+ track.drop(ChannelName.NOISE)
+
+ assert set(track.lattices) == {ChannelName.PULSE1}
+ assert set(track.stem_ids) == {ChannelName.PULSE1}
+
+
+class TestHierarchyMode:
+ def test_values(self) -> None:
+ assert tuple(HierarchyMode) == (HierarchyMode.ROUND_ROBIN, HierarchyMode.STRICT)
diff --git a/tests/unit/sampletones_core/reconstructions/reconstructor/test_matching.py b/tests/unit/sampletones_core/reconstructions/reconstructor/test_matching.py
new file mode 100644
index 000000000..e12e83bde
--- /dev/null
+++ b/tests/unit/sampletones_core/reconstructions/reconstructor/test_matching.py
@@ -0,0 +1,76 @@
+from __future__ import annotations
+
+import pytest
+
+from sampletones_core.configs import Config
+from sampletones_core.fft import Fragment, Window
+from sampletones_core.generators import (
+ get_generator_by_instruction,
+ get_remaining_generator_classes,
+)
+from sampletones_core.instructions import InstructionUnion
+from sampletones_core.library import InstructionLibraryData
+from sampletones_core.reconstructions.reconstructor.worker import ReconstructorWorker
+
+
+class TestTwoStageScoring:
+ def test_shortlist_is_ranked_by_aligned_cost_best_first(
+ self,
+ worker: ReconstructorWorker,
+ synthetic_fragment: Fragment,
+ ) -> None:
+ remaining_generator_classes = get_remaining_generator_classes(dict(worker.channels))
+ scored = worker.matcher.score_candidates(synthetic_fragment, remaining_generator_classes)
+
+ assert 0 < len(scored) <= worker.matcher.top_k
+ costs = [candidate.cost for candidate in scored]
+ assert costs == sorted(costs)
+
+ def test_phase_shifted_target_selects_its_source_instruction_at_near_zero_cost(
+ self,
+ worker: ReconstructorWorker,
+ library_data: InstructionLibraryData,
+ audible_instruction: InstructionUnion,
+ config: Config,
+ window: Window,
+ ) -> None:
+ """
+ A target that is a phase-shifted rendering of a library instruction wins with
+ a near-zero cost: the spectral shortlist is phase-independent, and the
+ temporal term is evaluated on the candidate aligned to the target, so the
+ phase accident carries no penalty.
+ """
+ instruction = audible_instruction
+ library_fragment = library_data[instruction]
+ shifted_target = library_fragment.get_fragment(library_fragment.length // 4, config, window)
+
+ remaining_generator_classes = get_remaining_generator_classes(dict(worker.channels))
+ scored = worker.matcher.score_candidates(shifted_target, remaining_generator_classes)
+
+ assert scored[0].instruction == instruction
+ assert scored[0].cost == pytest.approx(0.0, abs=1e-3)
+
+
+class TestClassRestrictedShortlist:
+ def test_one_class_keeps_the_candidate_a_wider_scoring_picked(
+ self,
+ worker: ReconstructorWorker,
+ synthetic_fragment: Fragment,
+ ) -> None:
+ """Scoring one generator class alone reaches the winner the wider scoring picked.
+
+ The shortlist is drawn by spectral rank, so a candidate that outranked every other
+ class's candidates outranks its own class's rejects too. That is what lets a frame's
+ ownership be settled across classes while the column the decoder reads holds the
+ winning channel's own alternatives.
+ """
+ wide_classes = get_remaining_generator_classes(dict(worker.channels))
+ winner = worker.matcher.score_candidates(synthetic_fragment, wide_classes)[0]
+ generator = get_generator_by_instruction(winner.instruction, wide_classes)
+
+ column = worker.matcher.score_candidates(
+ synthetic_fragment,
+ {generator.class_name(): generator},
+ )
+
+ assert winner.instruction in [candidate.instruction for candidate in column]
diff --git a/tests/unit/sampletones_core/reconstructions/reconstructor/test_reconstructor.py b/tests/unit/sampletones_core/reconstructions/reconstructor/test_reconstructor.py
index 50ec2fd16..3c61c6e16 100644
--- a/tests/unit/sampletones_core/reconstructions/reconstructor/test_reconstructor.py
+++ b/tests/unit/sampletones_core/reconstructions/reconstructor/test_reconstructor.py
@@ -7,14 +7,11 @@
import pytest
from sampletones_core.configs import Config
-from sampletones_core.fft import Fragment, FragmentedAudio, Window
+from sampletones_core.fft import Fragment, Window
from sampletones_core.generators import MIXER_LEVELS
from sampletones_core.library import InstructionLibraryData
-from sampletones_core.reconstructions.reconstructor.approximation import (
- ApproximationData,
-)
from sampletones_core.reconstructions.reconstructor.reconstructor import Reconstructor
-from sampletones_core.reconstructions.reconstructor.state import ReconstructionState
+from sampletones_core.reconstructions.reconstructor.stems.configs.config import StemsConfig
from sampletones_shared.exceptions import NoLibraryDataError
@@ -33,8 +30,8 @@ def test_generators_initialized_from_config(
library_data: InstructionLibraryData,
) -> None:
reconstructor = _make_reconstructor(config, library_data)
- expected_names = set(config.generation.generators)
- assert set(reconstructor.generators.keys()) == expected_names
+ expected_names = set(config.generation.channels)
+ assert set(reconstructor.channels.keys()) == expected_names
def test_window_created_from_config(
self,
@@ -65,9 +62,8 @@ def test_uniform_audio_anchors_to_its_level(
) -> None:
reconstructor = _make_reconstructor(config, library_data)
audio = np.ones(config.library.frame_length, dtype=np.float32) * 0.5
- coefficient = reconstructor.get_coefficient(audio)
- total_mixer = sum(MIXER_LEVELS[gen.class_name()] for gen in reconstructor.generators.values())
- assert coefficient == pytest.approx(0.5 / total_mixer)
+ coefficient = reconstructor.get_coefficient(audio, _full_setup(config))
+ assert coefficient == pytest.approx(0.5 / _total_mixer(reconstructor))
def test_coefficient_is_robust_to_a_lone_transient(
self,
@@ -76,10 +72,10 @@ def test_coefficient_is_robust_to_a_lone_transient(
) -> None:
reconstructor = _make_reconstructor(config, library_data)
frame_length = config.library.frame_length
- total_mixer = sum(MIXER_LEVELS[gen.class_name()] for gen in reconstructor.generators.values())
+ total_mixer = _total_mixer(reconstructor)
audio = np.full(frame_length * 24, 0.05, dtype=np.float32)
audio[:frame_length] = 1.0
- coefficient = reconstructor.get_coefficient(audio)
+ coefficient = reconstructor.get_coefficient(audio, _full_setup(config))
assert coefficient == pytest.approx(0.05 / total_mixer, rel=1e-3)
assert coefficient < 1.0 / total_mixer
@@ -89,9 +85,32 @@ def test_louder_audio_produces_larger_coefficient(
library_data: InstructionLibraryData,
) -> None:
reconstructor = _make_reconstructor(config, library_data)
+ setup = _full_setup(config)
quiet = np.ones(config.library.frame_length, dtype=np.float32) * 0.1
loud = np.ones(config.library.frame_length, dtype=np.float32) * 0.9
- assert reconstructor.get_coefficient(loud) > reconstructor.get_coefficient(quiet)
+ assert reconstructor.get_coefficient(loud, setup) > reconstructor.get_coefficient(quiet, setup)
+
+ def test_a_capped_setup_anchors_to_what_one_frame_reaches(
+ self,
+ config: Config,
+ library_data: InstructionLibraryData,
+ ) -> None:
+ """One channel per frame reaches one channel's weight, so that is what the level is measured against."""
+ reconstructor = _make_reconstructor(config, library_data)
+ capped = StemsConfig.single_entry(list(config.generation.channels), channel_cap=1)
+ audio = np.ones(config.library.frame_length, dtype=np.float32) * 0.5
+
+ loudest = max(MIXER_LEVELS[generator.class_name()] for generator in reconstructor.channels.values())
+
+ assert reconstructor.get_coefficient(audio, capped) == pytest.approx(0.5 / loudest)
+
+
+def _full_setup(config: Config) -> StemsConfig:
+ return StemsConfig.single_entry(list(config.generation.channels))
+
+
+def _total_mixer(reconstructor: Reconstructor) -> float:
+ return sum(MIXER_LEVELS[generator.class_name()] for generator in reconstructor.channels.values())
class TestReconstructorGetFragments:
@@ -119,7 +138,7 @@ def test_each_fragment_audio_length_matches_frame_length(
assert len(fragment.audio) == config.library.frame_length
-class TestReconstructorResetGenerators:
+class TestReconstructorResetChannels:
def test_reset_clears_generator_states(
self,
config: Config,
@@ -137,7 +156,7 @@ def test_reset_clears_generator_states(
)
reconstructor = _make_reconstructor(config, library_data)
- for generator in reconstructor.generators.values():
+ for generator in reconstructor.channels.values():
if isinstance(generator, PulseGenerator):
generator.save_state(True, PulseInstruction(on=True, pitch=60, volume=10, duty_cycle=0))
elif isinstance(generator, TriangleGenerator):
@@ -145,147 +164,88 @@ def test_reset_clears_generator_states(
elif isinstance(generator, NoiseGenerator):
generator.save_state(True, NoiseInstruction(on=True, period=0, volume=10, short=False))
- assert all(gen.previous_instruction is not None for gen in reconstructor.generators.values())
+ assert all(gen.previous_instruction is not None for gen in reconstructor.channels.values())
reconstructor.reset_generators()
- assert all(gen.previous_instruction is None for gen in reconstructor.generators.values())
+ assert all(gen.previous_instruction is None for gen in reconstructor.channels.values())
-class TestReconstructorUpdateState:
- def _setup(
- self,
- config: Config,
- library_data: InstructionLibraryData,
- synthetic_fragment: Fragment,
- final_regeneration: bool,
- ) -> tuple:
- updated_config = config.model_copy(
- update={
- "generation": config.generation.model_copy(
- update={
- "final_regeneration": final_regeneration,
- }
- )
- }
- )
- reconstructor = _make_reconstructor(updated_config, library_data)
- generator_name = next(iter(reconstructor.generators))
- instruction = next(
- instrument
- for instrument, frag in library_data.data.items()
- if frag.generator_class == reconstructor.generators[generator_name].class_name() and instrument.on
- )
- approximation_data = ApproximationData(
- generator_name=generator_name,
- approximation=synthetic_fragment,
- instruction=instruction,
- )
- reconstructor.state = ReconstructionState.create(list(reconstructor.generators.keys()))
- return reconstructor, generator_name, approximation_data
-
- def test_without_final_regeneration_stores_precomputed_audio_scaled_by_drive(
+class TestReconstructorCall:
+ def test_non_path_argument_raises_type_error(
self,
config: Config,
library_data: InstructionLibraryData,
- synthetic_fragment: Fragment,
) -> None:
- reconstructor, generator_name, approximation_data = self._setup(
- config,
- library_data,
- synthetic_fragment,
- final_regeneration=False,
- )
- reconstructor.update_state(approximation_data)
- expected = np.asarray(synthetic_fragment.audio) * reconstructor.config.generation.drive
- np.testing.assert_array_almost_equal(
- reconstructor.state.approximations[generator_name][0],
- expected,
- )
+ reconstructor = _make_reconstructor(config, library_data)
+ with pytest.raises(TypeError):
+ reconstructor(42) # type: ignore[arg-type]
- def test_without_final_regeneration_does_not_run_generator(
+ def test_returns_reconstruction_for_valid_audio_path(
self,
config: Config,
library_data: InstructionLibraryData,
synthetic_fragment: Fragment,
+ tmp_path: Path,
) -> None:
- reconstructor, generator_name, approximation_data = self._setup(
- config,
- library_data,
- synthetic_fragment,
- final_regeneration=False,
+ from sampletones_core.audio import write_wave
+ from sampletones_core.reconstructions.reconstruction.reconstruction import (
+ Reconstruction,
)
- reconstructor.update_state(approximation_data)
- assert reconstructor.generators[generator_name].previous_instruction is None
- def test_with_final_regeneration_reruns_generator(
- self,
- config: Config,
- library_data: InstructionLibraryData,
- synthetic_fragment: Fragment,
- ) -> None:
- reconstructor, generator_name, approximation_data = self._setup(
- config,
- library_data,
- synthetic_fragment,
- final_regeneration=True,
- )
- reconstructor.update_state(approximation_data)
- assert reconstructor.generators[generator_name].previous_instruction is approximation_data.instruction
+ audio_path = tmp_path / "test.wav"
+ audio = np.tile(synthetic_fragment.audio, 3).astype(np.float32)
+ write_wave(audio_path, config.library.sample_rate, audio)
+ reconstructor = _make_reconstructor(config, library_data)
+ result = reconstructor(audio_path)
+ assert isinstance(result, Reconstruction)
+
+
+class TestReconstructorFinalRegeneration:
+ """What a frame records: the instruction rendered afresh, or the audio it was matched on."""
+
+ def _tone_path(self, tmp_path: Path, config: Config, synthetic_fragment: Fragment) -> Path:
+ from sampletones_core.audio import write_wave
+ audio_path = tmp_path / "tone.wav"
+ write_wave(audio_path, config.library.sample_rate, np.tile(synthetic_fragment.audio, 3).astype(np.float32))
+ return audio_path
-class TestReconstructorReconstruct:
- def test_state_is_populated_for_each_fragment(
+ def _reconstructor(
self,
config: Config,
library_data: InstructionLibraryData,
- fragmented_audio: FragmentedAudio,
- ) -> None:
- reconstructor = _make_reconstructor(config, library_data)
- reconstructor.state = ReconstructionState.create(list(reconstructor.generators.keys()))
- reconstructor.reconstruct(fragmented_audio)
- fragment_count = len(fragmented_audio.fragments_ids)
- for generator_name in reconstructor.generators:
- assert len(reconstructor.state.instructions[generator_name]) == fragment_count
- assert len(reconstructor.state.approximations[generator_name]) == fragment_count
-
- def test_approximations_have_correct_frame_length(
+ final_regeneration: bool,
+ ) -> Reconstructor:
+ updated_config = config.model_copy(
+ update={"generation": config.generation.model_copy(update={"final_regeneration": final_regeneration})}
+ )
+ return _make_reconstructor(updated_config, library_data)
+
+ def test_final_regeneration_reruns_every_channel_generator(
self,
config: Config,
library_data: InstructionLibraryData,
- fragmented_audio: FragmentedAudio,
+ synthetic_fragment: Fragment,
+ tmp_path: Path,
) -> None:
- reconstructor = _make_reconstructor(config, library_data)
- reconstructor.state = ReconstructionState.create(list(reconstructor.generators.keys()))
- reconstructor.reconstruct(fragmented_audio)
- for generator_name in reconstructor.generators:
- for approximation in reconstructor.state.approximations[generator_name]:
- assert len(approximation) == config.library.frame_length
+ reconstructor = self._reconstructor(config, library_data, final_regeneration=True)
+ reconstruction = reconstructor(self._tone_path(tmp_path, config, synthetic_fragment))
-class TestReconstructorCall:
- def test_non_path_argument_raises_type_error(
- self,
- config: Config,
- library_data: InstructionLibraryData,
- ) -> None:
- reconstructor = _make_reconstructor(config, library_data)
- with pytest.raises(TypeError):
- reconstructor(42) # type: ignore[arg-type]
+ assert reconstruction is not None
+ for channel_name in reconstruction.playing_channels:
+ generator = reconstructor.channels[channel_name]
+ assert generator.previous_instruction is reconstruction.instructions[channel_name][-1]
- def test_returns_reconstruction_for_valid_audio_path(
+ def test_without_final_regeneration_the_matched_audio_stands(
self,
config: Config,
library_data: InstructionLibraryData,
synthetic_fragment: Fragment,
tmp_path: Path,
) -> None:
- from sampletones_core.audio import write_wave
- from sampletones_core.reconstructions.reconstruction.reconstruction import (
- Reconstruction,
- )
+ reconstructor = self._reconstructor(config, library_data, final_regeneration=False)
- audio_path = tmp_path / "test.wav"
- audio = np.tile(synthetic_fragment.audio, 3).astype(np.float32)
- write_wave(audio_path, config.library.sample_rate, audio)
- reconstructor = _make_reconstructor(config, library_data)
- result = reconstructor(audio_path)
- assert isinstance(result, Reconstruction)
+ reconstruction = reconstructor(self._tone_path(tmp_path, config, synthetic_fragment))
+
+ assert reconstruction is not None
+ assert all(generator.previous_instruction is None for generator in reconstructor.channels.values())
diff --git a/tests/unit/sampletones_core/reconstructions/reconstructor/test_scorer.py b/tests/unit/sampletones_core/reconstructions/reconstructor/test_scorer.py
index ea33f4797..b30eaea52 100644
--- a/tests/unit/sampletones_core/reconstructions/reconstructor/test_scorer.py
+++ b/tests/unit/sampletones_core/reconstructions/reconstructor/test_scorer.py
@@ -7,6 +7,7 @@
from sampletones_core.configs import Config
from sampletones_core.fft import Fragment, Window
+from sampletones_core.generators import get_remaining_generator_classes
from sampletones_core.instructions import InstructionUnion
from sampletones_core.library import InstructionLibraryData
from sampletones_core.reconstructions.reconstructor.scorer import Scorer
@@ -16,8 +17,7 @@
def _candidate_approximations(
worker: ReconstructorWorker,
) -> Tuple[Tuple[InstructionUnion, ...], Fragment]:
- remaining_generators = dict(worker.generators.items())
- remaining_generator_classes = worker.get_remaining_generator_classes(remaining_generators)
+ remaining_generator_classes = get_remaining_generator_classes(dict(worker.channels))
return worker.candidate_provider.candidates(remaining_generator_classes)
diff --git a/tests/unit/sampletones_core/reconstructions/reconstructor/test_selector.py b/tests/unit/sampletones_core/reconstructions/reconstructor/test_selector.py
deleted file mode 100644
index 5cd94e134..000000000
--- a/tests/unit/sampletones_core/reconstructions/reconstructor/test_selector.py
+++ /dev/null
@@ -1,49 +0,0 @@
-from __future__ import annotations
-
-import pytest
-
-from sampletones_core.configs import Config
-from sampletones_core.fft import Fragment, Window
-from sampletones_core.instructions import InstructionUnion
-from sampletones_core.library import InstructionLibraryData
-from sampletones_core.reconstructions.reconstructor.worker import ReconstructorWorker
-
-
-class TestTwoStageScoring:
- def test_shortlist_is_ranked_by_aligned_cost_best_first(
- self,
- worker: ReconstructorWorker,
- synthetic_fragment: Fragment,
- ) -> None:
- remaining_generators = dict(worker.generators.items())
- remaining_generator_classes = worker.get_remaining_generator_classes(remaining_generators)
- scored = worker.selector._score_candidates(synthetic_fragment, remaining_generator_classes)
-
- assert 0 < len(scored) <= worker.selector.top_k
- costs = [candidate.cost for candidate in scored]
- assert costs == sorted(costs)
-
- def test_phase_shifted_target_selects_its_source_instruction_at_near_zero_cost(
- self,
- worker: ReconstructorWorker,
- library_data: InstructionLibraryData,
- audible_instruction: InstructionUnion,
- config: Config,
- window: Window,
- ) -> None:
- """
- A target that is a phase-shifted rendering of a library instruction wins with
- a near-zero cost: the spectral shortlist is phase-independent, and the
- temporal term is evaluated on the candidate aligned to the target, so the
- phase accident carries no penalty.
- """
- instruction = audible_instruction
- library_fragment = library_data[instruction]
- shifted_target = library_fragment.get_fragment(library_fragment.length // 4, config, window)
-
- remaining_generators = dict(worker.generators.items())
- remaining_generator_classes = worker.get_remaining_generator_classes(remaining_generators)
- scored = worker.selector._score_candidates(shifted_target, remaining_generator_classes)
-
- assert scored[0].instruction == instruction
- assert scored[0].cost == pytest.approx(0.0, abs=1e-3)
diff --git a/tests/unit/sampletones_core/reconstructions/reconstructor/test_state.py b/tests/unit/sampletones_core/reconstructions/reconstructor/test_state.py
index acb462853..5efe8bed5 100644
--- a/tests/unit/sampletones_core/reconstructions/reconstructor/test_state.py
+++ b/tests/unit/sampletones_core/reconstructions/reconstructor/test_state.py
@@ -2,29 +2,20 @@
from dataclasses import dataclass
from typing import List
-from unittest.mock import MagicMock
import numpy as np
import pytest
-from sampletones_core.constants.enums import GeneratorName
-from sampletones_core.fft import Fragment
+from sampletones_core.constants.enums import ChannelName
from sampletones_core.instructions import PulseInstruction
-from sampletones_core.reconstructions.reconstructor.approximation import (
- ApproximationData,
-)
from sampletones_core.reconstructions.reconstructor.state import ReconstructionState
from tests.suite.case import BaseTestCase
_FRAME_LENGTH = 16
-def _make_approximation_data(generator_name: GeneratorName) -> ApproximationData:
- return ApproximationData(
- generator_name=generator_name,
- approximation=MagicMock(spec=Fragment),
- instruction=PulseInstruction(on=True, pitch=60, volume=10, duty_cycle=0),
- )
+def _make_instruction() -> PulseInstruction:
+ return PulseInstruction(on=True, pitch=60, volume=10, duty_cycle=0)
def _make_audio(value: float = 1.0) -> np.ndarray:
@@ -34,17 +25,17 @@ def _make_audio(value: float = 1.0) -> np.ndarray:
@dataclass(frozen=True, kw_only=True)
class NamesCase(BaseTestCase):
label: str
- names: List[GeneratorName]
+ names: List[ChannelName]
NAMES_CASES = [
- NamesCase(label="single", names=[GeneratorName.PULSE1]),
+ NamesCase(label="single", names=[ChannelName.PULSE1]),
NamesCase(
label="multiple",
names=[
- GeneratorName.PULSE1,
- GeneratorName.TRIANGLE,
- GeneratorName.NOISE,
+ ChannelName.PULSE1,
+ ChannelName.TRIANGLE,
+ ChannelName.NOISE,
],
),
]
@@ -62,7 +53,7 @@ def test_empty_list_creates_empty_approximation_dicts(self) -> None:
@pytest.mark.parametrize("case", NAMES_CASES, ids=lambda c: c.label)
def test_generator_names_stored(self, case: NamesCase) -> None:
state = ReconstructionState.create(case.names)
- assert state.generator_names == case.names
+ assert state.channel_names == case.names
@pytest.mark.parametrize("case", NAMES_CASES, ids=lambda c: c.label)
def test_each_generator_initializes_to_empty_list(self, case: NamesCase) -> None:
@@ -86,30 +77,51 @@ class TestCase(BaseTestCase):
@pytest.fixture
def state(self) -> ReconstructionState:
- return ReconstructionState.create([GeneratorName.PULSE1, GeneratorName.TRIANGLE])
+ return ReconstructionState.create([ChannelName.PULSE1, ChannelName.TRIANGLE])
def test_instruction_added_for_correct_generator(self, state: ReconstructionState) -> None:
- approximation_data = _make_approximation_data(GeneratorName.PULSE1)
- state.append(approximation_data, _make_audio())
- assert state.instructions[GeneratorName.PULSE1] == [approximation_data.instruction]
+ instruction = _make_instruction()
+ state.append(ChannelName.PULSE1, instruction, _make_audio())
+ assert state.instructions[ChannelName.PULSE1] == [instruction]
def test_approximation_added_for_correct_generator(self, state: ReconstructionState) -> None:
audio = _make_audio(0.5)
- state.append(_make_approximation_data(GeneratorName.PULSE1), audio)
- assert len(state.approximations[GeneratorName.PULSE1]) == 1
- np.testing.assert_array_equal(state.approximations[GeneratorName.PULSE1][0], audio)
+ state.append(ChannelName.PULSE1, _make_instruction(), audio)
+ assert len(state.approximations[ChannelName.PULSE1]) == 1
+ np.testing.assert_array_equal(state.approximations[ChannelName.PULSE1][0], audio)
@pytest.mark.parametrize("case", ACCUMULATE_CASES, ids=lambda c: c.label)
def test_multiple_appends_accumulate_in_order(self, case: TestCase, state: ReconstructionState) -> None:
- for i in range(case.count):
- state.append(_make_approximation_data(GeneratorName.PULSE1), _make_audio(float(i)))
- assert len(state.instructions[GeneratorName.PULSE1]) == case.count
- assert len(state.approximations[GeneratorName.PULSE1]) == case.count
+ for index in range(case.count):
+ state.append(ChannelName.PULSE1, _make_instruction(), _make_audio(float(index)))
+ assert len(state.instructions[ChannelName.PULSE1]) == case.count
+ assert len(state.approximations[ChannelName.PULSE1]) == case.count
def test_append_to_separate_generators_are_independent(self, state: ReconstructionState) -> None:
- state.append(_make_approximation_data(GeneratorName.PULSE1), _make_audio(1.0))
- state.append(_make_approximation_data(GeneratorName.TRIANGLE), _make_audio(2.0))
- assert len(state.instructions[GeneratorName.PULSE1]) == 1
- assert len(state.approximations[GeneratorName.PULSE1]) == 1
- assert len(state.instructions[GeneratorName.TRIANGLE]) == 1
- assert len(state.approximations[GeneratorName.TRIANGLE]) == 1
+ state.append(ChannelName.PULSE1, _make_instruction(), _make_audio(1.0))
+ state.append(ChannelName.TRIANGLE, _make_instruction(), _make_audio(2.0))
+ assert len(state.instructions[ChannelName.PULSE1]) == 1
+ assert len(state.approximations[ChannelName.PULSE1]) == 1
+ assert len(state.instructions[ChannelName.TRIANGLE]) == 1
+ assert len(state.approximations[ChannelName.TRIANGLE]) == 1
+
+
+class TestReconstructionStateDrop:
+ @pytest.fixture
+ def state(self) -> ReconstructionState:
+ state = ReconstructionState.create([ChannelName.PULSE1, ChannelName.TRIANGLE])
+ state.append(ChannelName.PULSE1, _make_instruction(), _make_audio())
+ return state
+
+ def test_dropping_a_channel_releases_its_stream(self, state: ReconstructionState) -> None:
+ state.drop(ChannelName.TRIANGLE)
+
+ assert state.channel_names == [ChannelName.PULSE1]
+ assert set(state.instructions) == {ChannelName.PULSE1}
+ assert set(state.approximations) == {ChannelName.PULSE1}
+
+ def test_the_remaining_channels_keep_their_frames(self, state: ReconstructionState) -> None:
+ state.drop(ChannelName.TRIANGLE)
+
+ assert len(state.instructions[ChannelName.PULSE1]) == 1
+ assert len(state.approximations[ChannelName.PULSE1]) == 1
diff --git a/tests/unit/sampletones_core/reconstructions/reconstructor/test_worker.py b/tests/unit/sampletones_core/reconstructions/reconstructor/test_worker.py
index 05c23f91a..8e0779aef 100644
--- a/tests/unit/sampletones_core/reconstructions/reconstructor/test_worker.py
+++ b/tests/unit/sampletones_core/reconstructions/reconstructor/test_worker.py
@@ -1,165 +1,54 @@
from __future__ import annotations
-from typing import Any, Dict, List
-
-import numpy as np
+from typing import Dict
from sampletones_core.configs import Config
-from sampletones_core.constants.enums import GeneratorName
-from sampletones_core.fft import Fragment, Window
+from sampletones_core.constants.enums import ChannelName, SelectorName
+from sampletones_core.fft import Window
from sampletones_core.generators import GeneratorUnion
from sampletones_core.library import InstructionLibraryData
-from sampletones_core.reconstructions.reconstructor.reconstructor import reconstruct
+from sampletones_core.reconstructions.reconstructor.decoder import DECODERS
from sampletones_core.reconstructions.reconstructor.worker import ReconstructorWorker
-class TestReconstructorWorkerHelpers:
- def test_get_remaining_generator_classes_maps_by_class_name(
- self,
- worker: ReconstructorWorker,
- generators: Dict[GeneratorName, GeneratorUnion],
- ) -> None:
- remaining = dict(worker.generators.items())
- by_class = worker.get_remaining_generator_classes(remaining)
- expected_class_names = {gen.class_name() for gen in generators.values()}
- assert set(by_class.keys()) == expected_class_names
-
-
-class TestReconstructorWorkerIntegration:
- def test_call_returns_dict_keyed_by_fragment_id(
- self,
- worker: ReconstructorWorker,
- fragmented_audio: Any,
- ) -> None:
- fragment_ids = fragmented_audio.fragments_ids
- result = worker(fragmented_audio, fragment_ids)
- assert set(result.keys()) == set(fragment_ids)
-
- def test_call_result_has_one_entry_per_generator(
- self,
- worker: ReconstructorWorker,
- fragmented_audio: Any,
- generators: Dict[GeneratorName, GeneratorUnion],
- ) -> None:
- result = worker(fragmented_audio, [fragmented_audio.fragments_ids[0]])
- per_fragment = next(iter(result.values()))
- assert set(per_fragment.keys()) == set(generators.keys())
-
- def test_approximation_data_has_valid_generator_name(
- self,
- worker: ReconstructorWorker,
- generators: Dict[GeneratorName, GeneratorUnion],
- synthetic_fragment: Fragment,
- ) -> None:
- result = worker.reconstruct(synthetic_fragment)
- for generator_name in result:
- assert generator_name in generators
+def _worker_with_selector(
+ config: Config,
+ window: Window,
+ channels: Dict[ChannelName, GeneratorUnion],
+ library_data: InstructionLibraryData,
+ selector_name: SelectorName,
+) -> ReconstructorWorker:
+ decoder = config.generation.decoder.model_copy(update={"selector": selector_name})
+ updated_config = config.model_copy(update={"generation": config.generation.model_copy(update={"decoder": decoder})})
+ return ReconstructorWorker(
+ config=updated_config,
+ window=window,
+ channels=channels,
+ library_data=library_data,
+ signal_length=config.library.frame_length,
+ )
- def test_combined_approximation_is_not_all_zeros(
- self,
- worker: ReconstructorWorker,
- synthetic_fragment: Fragment,
- ) -> None:
- result = worker.reconstruct(synthetic_fragment)
- combined = sum(np.asarray(approx_data.approximation.audio) for approx_data in result.values())
- assert not np.all(combined == 0.0)
- def test_reconstruction_reduces_residual(
- self,
- worker: ReconstructorWorker,
- synthetic_fragment: Fragment,
- ) -> None:
- original_audio = np.asarray(synthetic_fragment.audio).copy()
- result = worker.reconstruct(synthetic_fragment)
- residual_audio = original_audio.copy()
- for approximation_data in result.values():
- residual_audio = residual_audio - np.asarray(approximation_data.approximation.audio)
- original_rmse = float(np.sqrt(np.mean(original_audio**2)))
- residual_rmse = float(np.sqrt(np.mean(residual_audio**2)))
- assert residual_rmse < original_rmse
-
- def test_without_find_best_phase_produces_valid_approximation(
+class TestWorkerDecoder:
+ def test_the_configured_selector_names_the_decoder(
self,
config: Config,
window: Window,
- generators: Dict[GeneratorName, GeneratorUnion],
+ channels: Dict[ChannelName, GeneratorUnion],
library_data: InstructionLibraryData,
) -> None:
- updated_config = config.model_copy(
- update={
- "generation": config.generation.model_copy(
- update={
- "calculation": config.generation.calculation.model_copy(
- update={
- "find_best_phase": False,
- }
- )
- }
- )
- }
- )
- active_instruction = next(instrument for instrument in library_data.keys() if instrument.on)
- fragment = library_data[active_instruction].get_fragment(0, updated_config, window)
- local_worker = ReconstructorWorker(
- config=updated_config,
- window=window,
- generators=generators,
- library_data=library_data,
- signal_length=1 << 20,
- )
- result = local_worker.reconstruct(fragment)
- combined = sum(np.asarray(approximation_data.approximation.audio) for approximation_data in result.values())
- assert not np.all(combined == 0.0)
+ for selector_name in SelectorName:
+ worker = _worker_with_selector(config, window, channels, library_data, selector_name)
+ assert isinstance(worker.decoder, DECODERS[selector_name])
+ def test_the_default_configuration_builds_the_decoder_it_names(self, worker: ReconstructorWorker) -> None:
+ assert isinstance(worker.decoder, DECODERS[worker.config.generation.decoder.selector])
-class TestModuleLevelReconstruct:
- def _call(
- self,
- worker: ReconstructorWorker,
- fragmented_audio: Any,
- library_data: InstructionLibraryData,
- fragment_ids: List[int],
- ) -> Any:
- return reconstruct(
- fragments_ids=fragment_ids,
- fragmented_audio=fragmented_audio,
- config=worker.config,
- window=worker.window,
- generators=worker.generators,
- library_data=library_data,
- )
-
- def test_returns_dict_keyed_by_fragment_id(
- self,
- worker: ReconstructorWorker,
- fragmented_audio: Any,
- library_data: InstructionLibraryData,
- ) -> None:
- fragment_ids = [fragmented_audio.fragments_ids[0]]
- result = self._call(worker, fragmented_audio, library_data, fragment_ids)
- assert set(result.keys()) == set(fragment_ids)
- def test_covers_all_requested_fragment_ids(
- self,
- worker: ReconstructorWorker,
- fragmented_audio: Any,
- library_data: InstructionLibraryData,
- ) -> None:
- fragment_ids = fragmented_audio.fragments_ids
- result = self._call(worker, fragmented_audio, library_data, fragment_ids)
- assert set(result.keys()) == set(fragment_ids)
-
- def test_same_inputs_produce_same_instructions(
- self,
- worker: ReconstructorWorker,
- fragmented_audio: Any,
- library_data: InstructionLibraryData,
- ) -> None:
- fragment_ids = [fragmented_audio.fragments_ids[0]]
- result_a = self._call(worker, fragmented_audio, library_data, fragment_ids)
- result_b = self._call(worker, fragmented_audio, library_data, fragment_ids)
- for generator_name in result_a[fragment_ids[0]]:
- assert (
- result_a[fragment_ids[0]][generator_name].instruction
- == result_b[fragment_ids[0]][generator_name].instruction
- )
+class TestWorkerMatcher:
+ def test_the_matcher_scores_through_the_workers_own_machinery(self, worker: ReconstructorWorker) -> None:
+ """One scorer, provider and aligner serve both the matching and everything built on it."""
+ assert worker.matcher.scorer is worker.scorer
+ assert worker.matcher.candidate_provider is worker.candidate_provider
+ assert worker.matcher.phase_aligner is worker.phase_aligner
+ assert worker.matcher.config is worker.config
diff --git a/tests/unit/sampletones_core/reconstructions/test_progress.py b/tests/unit/sampletones_core/reconstructions/test_progress.py
new file mode 100644
index 000000000..3e6194c80
--- /dev/null
+++ b/tests/unit/sampletones_core/reconstructions/test_progress.py
@@ -0,0 +1,134 @@
+from dataclasses import dataclass
+from typing import List, Tuple
+
+import pytest
+
+from sampletones_core.reconstructions.progress import (
+ ReconstructionProgress,
+ announce,
+)
+from sampletones_core.reconstructions.stage import STAGE_WEIGHTS, ReconstructionStage
+from sampletones_shared.exceptions import OperationCanceled
+from sampletones_shared.utils.progress import silent_reporter
+from tests.suite.base import BaseTestSuite
+from tests.suite.case import BaseAutolabelTestCase
+from tests.suite.progress import FIRST_REPORT, RecordingReporter
+
+WHOLE: float = 1.0
+FRAMES: int = 8
+
+
+class TestStageWeights(BaseTestSuite):
+ """Every stage a reconstruction passes through carries a weight, and together they are the run.
+
+ A bar crossing the stages reads each one through its weight, so a stage the weights forgot
+ would leave the bar standing still while that stage ran. What each weight is worth is a tuning
+ choice; that they divide the whole run between them is the contract.
+ """
+
+ def test_every_stage_carries_a_weight(self) -> None:
+ assert set(STAGE_WEIGHTS) == set(ReconstructionStage)
+
+ def test_the_shares_are_the_whole_run(self) -> None:
+ assert sum(stage.share for stage in ReconstructionStage) == pytest.approx(WHOLE)
+
+ def test_a_stage_begins_where_the_stages_before_it_end(self) -> None:
+ stages = list(ReconstructionStage)
+ offsets = [stage.offset for stage in stages]
+
+ assert offsets[0] == pytest.approx(0.0)
+ for stage, offset, following in zip(stages, offsets, offsets[1:]):
+ assert following == pytest.approx(offset + stage.share)
+
+ def test_a_finished_run_arrives_exactly_at_its_end(self) -> None:
+ """A bar drawn from this reads full only where the reading lands on the whole run."""
+ last = list(ReconstructionStage)[-1]
+ finished = ReconstructionProgress(stage=last, completed=FRAMES, total=FRAMES)
+
+ assert finished.fraction == WHOLE
+
+
+class TestReconstructionFraction(BaseTestSuite):
+ """A stage's own count reads as a fraction of the whole reconstruction.
+
+ The stages count in units of their own — frames here, an arrival there — so what a bar shows
+ is each stage's progress taken through the share it holds, which is what lets one reading span
+ a run that changes what it is counting three times over.
+ """
+
+ @dataclass(frozen=True, kw_only=True)
+ class TestCase(BaseAutolabelTestCase):
+ expected: float
+ stage: ReconstructionStage
+ covered: float
+
+ @property
+ def label(self) -> str:
+ return f"{self.stage}_{self.covered:.0%}_through"
+
+ test_cases = (
+ TestCase(stage=ReconstructionStage.LOADING, covered=0.0, expected=0.0),
+ TestCase(stage=ReconstructionStage.MATCHING, covered=0.0, expected=0.0),
+ TestCase(stage=ReconstructionStage.MATCHING, covered=0.5, expected=0.5),
+ TestCase(stage=ReconstructionStage.MATCHING, covered=WHOLE, expected=WHOLE),
+ TestCase(stage=ReconstructionStage.RENDERING, covered=WHOLE, expected=WHOLE),
+ )
+
+ @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label)
+ def test_the_fraction_weighs_the_stage_by_its_share(self, test_case: TestCase) -> None:
+ """A stage part of the way through reads as that part of the share it holds.
+
+ The expectation is derived from the stage's own share rather than restating it, so tuning
+ what a stage is worth leaves the rule it must satisfy standing.
+ """
+ completed = round(test_case.covered * FRAMES)
+ progress = ReconstructionProgress(
+ stage=test_case.stage,
+ completed=completed,
+ total=FRAMES,
+ )
+ expected = test_case.stage.offset + test_case.stage.share * test_case.expected
+
+ assert progress.fraction == pytest.approx(expected)
+
+ def test_a_stage_measured_against_nothing_reads_as_its_own_beginning(self) -> None:
+ progress = ReconstructionProgress(stage=ReconstructionStage.DECODING, completed=0, total=0)
+
+ assert progress.fraction == pytest.approx(ReconstructionStage.DECODING.offset)
+
+ def test_a_run_read_in_stage_order_never_turns_back(self) -> None:
+ readings: List[float] = []
+ for stage in ReconstructionStage:
+ for completed in range(FRAMES + 1):
+ readings.append(ReconstructionProgress(stage=stage, completed=completed, total=FRAMES).fraction)
+
+ assert readings == sorted(readings)
+ assert readings[0] == pytest.approx(0.0)
+ assert readings[-1] == pytest.approx(WHOLE)
+
+
+class TestAnnounce(BaseTestSuite):
+ """A run tells its reporter how far it has come, and unwinds where the reporter withdraws it."""
+
+ def test_a_report_carries_the_stage_and_its_counts(self) -> None:
+ reporter: RecordingReporter[ReconstructionProgress] = RecordingReporter()
+
+ announce(reporter, ReconstructionStage.MATCHING, 3, FRAMES)
+
+ assert reporter.last == ReconstructionProgress(
+ stage=ReconstructionStage.MATCHING,
+ completed=3,
+ total=FRAMES,
+ )
+
+ def test_a_withdrawn_run_unwinds_where_it_stood(self) -> None:
+ reporter: RecordingReporter[ReconstructionProgress] = RecordingReporter(withdraw_at=FIRST_REPORT)
+
+ with pytest.raises(OperationCanceled):
+ announce(reporter, ReconstructionStage.MATCHING, 0, FRAMES)
+
+ def test_a_caller_watching_nothing_hears_the_run_through(self) -> None:
+ readings: Tuple[int, ...] = (0, FRAMES // 2, FRAMES)
+
+ for completed in readings:
+ announce(silent_reporter, ReconstructionStage.RENDERING, completed, FRAMES)
diff --git a/tests/unit/sampletones_core/structures/collection/test_bidirectional.py b/tests/unit/sampletones_core/structures/collection/test_bidirectional.py
index 664c1be28..f62f5403d 100644
--- a/tests/unit/sampletones_core/structures/collection/test_bidirectional.py
+++ b/tests/unit/sampletones_core/structures/collection/test_bidirectional.py
@@ -1,4 +1,4 @@
-from typing import Any, Tuple, Union
+from typing import Any, FrozenSet, Tuple, Union
import pytest
@@ -561,7 +561,7 @@ def test_tuple_as_value(self) -> None:
assert bidirectional[(1, 2, 3)] == "point"
def test_frozenset_as_value(self) -> None:
- bidirectional = BidirectionalHashMap[frozenset[str]]()
+ bidirectional = BidirectionalHashMap[FrozenSet[str]]()
value = frozenset({"a", "b", "c"})
bidirectional["set1"] = value
assert bidirectional["set1"] == value
@@ -873,7 +873,7 @@ def test_eq_with_different_bidirectional(self) -> None:
assert bidi1 != bidi2
def test_eq_with_frozenset(self) -> None:
- bidirectional = BidirectionalHashMap[frozenset[int]]()
+ bidirectional = BidirectionalHashMap[FrozenSet[int]]()
set1 = frozenset([1, 2, 3])
set2 = frozenset([3, 2, 1])
diff --git a/tests/unit/sampletones_core/structures/tree/test_node.py b/tests/unit/sampletones_core/structures/tree/test_node.py
index 15482d80f..880bd9926 100644
--- a/tests/unit/sampletones_core/structures/tree/test_node.py
+++ b/tests/unit/sampletones_core/structures/tree/test_node.py
@@ -1,7 +1,7 @@
from pathlib import Path
from sampletones_core.configs import Config
-from sampletones_core.constants.enums import LibraryGeneratorName
+from sampletones_core.constants.enums import GeneratorName
from sampletones_core.library import InstructionLibraryKey
from sampletones_core.reconstructions.converter.paths import ConfigDirectoryFields
from sampletones_core.structures.tree.node import (
@@ -131,14 +131,14 @@ def test_default_node_type_is_library(self) -> None:
class TestGeneratorNode:
def test_generator_name_is_stored(self) -> None:
- node = GeneratorNode("gen", generator_name=LibraryGeneratorName.PULSE)
- assert node.generator_name == LibraryGeneratorName.PULSE
+ node = GeneratorNode("gen", generator_name=GeneratorName.PULSE)
+ assert node.generator_name == GeneratorName.PULSE
def test_copy_preserves_generator_name(self) -> None:
- node = GeneratorNode("gen", generator_name=LibraryGeneratorName.PULSE)
+ node = GeneratorNode("gen", generator_name=GeneratorName.PULSE)
copied = node.copy()
- assert copied.generator_name == LibraryGeneratorName.PULSE
+ assert copied.generator_name == GeneratorName.PULSE
def test_default_node_type_is_generator(self) -> None:
- node = GeneratorNode("gen", generator_name=LibraryGeneratorName.PULSE)
+ node = GeneratorNode("gen", generator_name=GeneratorName.PULSE)
assert node.node_type == NodeType.GENERATOR
diff --git a/tests/unit/sampletones_core/structures/tree/test_visibility.py b/tests/unit/sampletones_core/structures/tree/test_visibility.py
index abd278307..944fe0f34 100644
--- a/tests/unit/sampletones_core/structures/tree/test_visibility.py
+++ b/tests/unit/sampletones_core/structures/tree/test_visibility.py
@@ -1,5 +1,5 @@
from dataclasses import dataclass
-from typing import Dict, List
+from typing import Dict, FrozenSet, List
import pytest
@@ -42,7 +42,7 @@ class TestVisibleRows:
class TestCase(BaseTestCase):
label: str
matched_names: List[str]
- expected_visible_names: frozenset[str]
+ expected_visible_names: FrozenSet[str]
test_cases = (
TestCase(
diff --git a/tests/unit/sampletones_core/timers/implementation/test_phase.py b/tests/unit/sampletones_core/timers/implementation/test_phase.py
index b0512259c..404ec03be 100644
--- a/tests/unit/sampletones_core/timers/implementation/test_phase.py
+++ b/tests/unit/sampletones_core/timers/implementation/test_phase.py
@@ -15,48 +15,6 @@ def phase_timer() -> PhaseTimer:
return PhaseTimer(sample_rate=44100, nes_frequency=60)
-class TestFrequencyToTimer:
- @pytest.mark.parametrize(
- "frequency, expected",
- [
- (0, 0),
- (-1.0, 0),
- (440.0, 253),
- (0.001, 0x7FF),
- (1e9, 0),
- ],
- ids=[
- "zero_frequency",
- "negative_frequency",
- "a4_440hz",
- "very_low_clamps_at_max",
- "very_high_clamps_at_zero",
- ],
- )
- def test_timer_value_correct(self, frequency: float, expected: int) -> None:
- assert PhaseTimer.frequency_to_timer(frequency) == expected
-
-
-class TestGetTimerTicks:
- @pytest.mark.parametrize(
- "timer, expected",
- [
- (0, 0),
- (-1, 0),
- (1, 32),
- (100, 1616),
- ],
- ids=[
- "zero_returns_zero",
- "negative_returns_zero",
- "timer_1_gives_32",
- "timer_100_gives_1616",
- ],
- )
- def test_tick_count_correct(self, timer: int, expected: int) -> None:
- assert PhaseTimer.get_timer_ticks(timer) == expected
-
-
class TestPhaseTimerValidate:
@pytest.mark.parametrize(
"initials",
diff --git a/tests/unit/sampletones_core/timers/test_arithmetic.py b/tests/unit/sampletones_core/timers/test_arithmetic.py
new file mode 100644
index 000000000..40b8bf4bc
--- /dev/null
+++ b/tests/unit/sampletones_core/timers/test_arithmetic.py
@@ -0,0 +1,105 @@
+from dataclasses import dataclass
+
+import pytest
+
+from sampletones_core.configs import Config
+from sampletones_core.constants.general import MAX_TIMER, TIMER_CYCLE_DIVIDER
+from sampletones_core.timers.arithmetic import (
+ frequency_to_timer,
+ get_timer_ticks,
+ timer_to_frequency,
+)
+from sampletones_core.timers.utils import get_frequency_table, get_timer_table
+from sampletones_shared.constants.music import A4_PITCH, LIMIT_MAX_PITCH, LIMIT_MIN_PITCH
+from sampletones_shared.music import Tuning
+from tests.suite.base import BaseTestSuite
+from tests.suite.case import BaseAutolabelTestCase
+
+
+class TestFrequencyToTimer(BaseTestSuite):
+ """The expected timers are the hardware's own values, which the APU reads back directly.
+
+ A frequency beyond the 11-bit register's reach settles on the endpoint nearest it.
+ """
+
+ @dataclass(frozen=True, kw_only=True)
+ class TestCase(BaseAutolabelTestCase):
+ expected: int
+ frequency: float
+
+ @property
+ def label(self) -> str:
+ return f"frequency_{self.frequency:g}"
+
+ test_cases = (
+ TestCase(frequency=0.0, expected=0),
+ TestCase(frequency=-1.0, expected=0),
+ TestCase(frequency=440.0, expected=253),
+ TestCase(frequency=0.001, expected=MAX_TIMER),
+ TestCase(frequency=1e9, expected=0),
+ )
+
+ @pytest.mark.parametrize(
+ "test_case",
+ test_cases,
+ ids=lambda test_case: test_case.label,
+ )
+ def test_timer_matches(self, test_case: TestCase) -> None:
+ assert frequency_to_timer(test_case.frequency) == test_case.expected
+
+
+class TestGetTimerTicks(BaseTestSuite):
+ @dataclass(frozen=True, kw_only=True)
+ class TestCase(BaseAutolabelTestCase):
+ expected: int
+ timer: int
+
+ @property
+ def label(self) -> str:
+ return f"timer_{self.timer}"
+
+ test_cases = (
+ TestCase(timer=0, expected=0),
+ TestCase(timer=-1, expected=0),
+ TestCase(timer=1, expected=2 * TIMER_CYCLE_DIVIDER),
+ TestCase(timer=100, expected=101 * TIMER_CYCLE_DIVIDER),
+ )
+
+ @pytest.mark.parametrize(
+ "test_case",
+ test_cases,
+ ids=lambda test_case: test_case.label,
+ )
+ def test_cycle_count_matches(self, test_case: TestCase) -> None:
+ assert get_timer_ticks(test_case.timer) == test_case.expected
+
+
+class TestTimerRoundTrip:
+ """Reading a timer back as a frequency and converting it again returns the same timer.
+
+ This is what lets a timer table be derived from a frequency table without drifting: the
+ frequency a table holds is already one the divider reaches exactly.
+ """
+
+ @pytest.mark.parametrize("timer", [0, 1, 8, 253, 1000, MAX_TIMER])
+ def test_frequency_maps_back_to_its_timer(self, timer: int) -> None:
+ assert frequency_to_timer(timer_to_frequency(timer)) == timer
+
+
+class TestGetTimerTable:
+ def test_covers_every_pitch_the_project_sounds(self) -> None:
+ timers = get_timer_table(Tuning())
+ assert set(timers) == set(range(LIMIT_MIN_PITCH, LIMIT_MAX_PITCH + 1))
+
+ def test_every_timer_sounds_its_pitch_frequency(self) -> None:
+ config = Config()
+ frequencies = get_frequency_table(config)
+ timers = get_timer_table(config.tuning)
+
+ for pitch, frequency in frequencies.items():
+ assert timer_to_frequency(timers[pitch]) == frequency
+
+ def test_a_retuned_table_follows_concert_pitch(self) -> None:
+ standard = get_timer_table(Tuning())
+ retuned = get_timer_table(Tuning(a4_frequency=432.0))
+ assert retuned[A4_PITCH] > standard[A4_PITCH]
diff --git a/tests/unit/sampletones_core/timing/test_groove.py b/tests/unit/sampletones_core/timing/test_groove.py
index f728dd2b3..5dc4840cf 100644
--- a/tests/unit/sampletones_core/timing/test_groove.py
+++ b/tests/unit/sampletones_core/timing/test_groove.py
@@ -7,7 +7,7 @@
import pytest
from sampletones_core.timing.groove import Groove, calculate_groove
-from sampletones_core.timing.metre import Metre
+from sampletones_core.timing.meter import Meter
from sampletones_core.timing.rate import RowRate
from sampletones_shared.constants.project import REFERENCE_NES_FREQUENCY, REFERENCE_TEMPO
from tests.suite.base import BaseTestSuite
@@ -43,8 +43,8 @@ def label(self) -> str:
)
@property
- def metre(self) -> Metre:
- return Metre(
+ def meter(self) -> Meter:
+ return Meter(
rows=self.rows,
first_highlight=self.first_highlight,
second_highlight=self.second_highlight,
@@ -58,7 +58,7 @@ def groove(self) -> Groove:
speed=self.speed,
nes_frequency=self.nes_frequency,
),
- self.metre,
+ self.meter,
minimum_ticks=MINIMUM_TICKS,
maximum_ticks=MAXIMUM_TICKS,
)
@@ -749,7 +749,7 @@ def test_every_row_lies_within_the_engine_range(self, test_case: TestCase) -> No
test_cases,
ids=lambda test_case: test_case.label,
)
- def test_every_row_neighbours_the_average(self, test_case: TestCase) -> None:
+ def test_every_row_neighbors_the_average(self, test_case: TestCase) -> None:
groove = test_case.groove
shorter, remainder = divmod(groove.total_ticks, test_case.rows)
longer = shorter + 1 if remainder else shorter
@@ -775,7 +775,7 @@ def test_longer_rows_come_first(self, test_case: TestCase) -> None:
def test_each_beat_opens_on_its_longest_row(self, test_case: TestCase) -> None:
ticks = test_case.groove.ticks
start = 0
- for beats in test_case.metre.spans:
+ for beats in test_case.meter.spans:
for beat_rows in beats:
beat = ticks[start : start + beat_rows]
assert beat[0] == max(beat)
@@ -794,7 +794,7 @@ def test_every_row_lasts_speed_ticks(self, speed: int, rows: int) -> None:
speed=speed,
nes_frequency=REFERENCE_NES_FREQUENCY,
),
- Metre(
+ Meter(
rows=rows,
first_highlight=COMMON_TIME_BEAT,
second_highlight=COMMON_TIME_BAR,
@@ -817,7 +817,7 @@ def _groove(rows: int, first_highlight: int, second_highlight: int, tempo: int)
speed=REFERENCE_SPEED,
nes_frequency=60,
),
- Metre(
+ Meter(
rows=rows,
first_highlight=first_highlight,
second_highlight=second_highlight,
diff --git a/tests/unit/sampletones_core/timing/test_metre.py b/tests/unit/sampletones_core/timing/test_meter.py
similarity index 91%
rename from tests/unit/sampletones_core/timing/test_metre.py
rename to tests/unit/sampletones_core/timing/test_meter.py
index a9d7711ee..4b31d0eb4 100644
--- a/tests/unit/sampletones_core/timing/test_metre.py
+++ b/tests/unit/sampletones_core/timing/test_meter.py
@@ -4,7 +4,7 @@
import pytest
from sampletones_core.project.settings import ProjectSettings
-from sampletones_core.timing.metre import Metre
+from sampletones_core.timing.meter import Meter
from tests.suite.base import BaseTestSuite
from tests.suite.case import BaseAutolabelTestCase
@@ -90,12 +90,12 @@ def label(self) -> str:
ids=lambda test_case: test_case.label,
)
def test_spans_match(self, test_case: TestCase) -> None:
- metre = Metre(
+ meter = Meter(
rows=test_case.rows,
first_highlight=test_case.first_highlight,
second_highlight=test_case.second_highlight,
)
- assert metre.spans == test_case.expected
+ assert meter.spans == test_case.expected
@pytest.mark.parametrize(
"test_case",
@@ -103,12 +103,12 @@ def test_spans_match(self, test_case: TestCase) -> None:
ids=lambda test_case: test_case.label,
)
def test_spans_cover_the_pattern(self, test_case: TestCase) -> None:
- metre = Metre(
+ meter = Meter(
rows=test_case.rows,
first_highlight=test_case.first_highlight,
second_highlight=test_case.second_highlight,
)
- assert sum(sum(beats) for beats in metre.spans) == test_case.rows
+ assert sum(sum(beats) for beats in meter.spans) == test_case.rows
class TestBounds(BaseTestSuite):
@@ -144,18 +144,18 @@ def label(self) -> str:
def test_field_below_one_is_rejected(self, test_case: TestCase) -> None:
fields = {"rows": 16, "first_highlight": 4, "second_highlight": 16, test_case.field: 0}
with pytest.raises(ValueError, match=test_case.expected):
- Metre(**fields)
+ Meter(**fields)
class TestProjectSettings:
def test_settings_state_the_highlights(self) -> None:
settings = ProjectSettings(first_highlight=3, second_highlight=12)
- assert Metre.from_settings(settings, rows=24) == Metre(
+ assert Meter.from_settings(settings, rows=24) == Meter(
rows=24,
first_highlight=3,
second_highlight=12,
)
def test_the_default_settings_state_common_time(self) -> None:
- metre = Metre.from_settings(ProjectSettings(), rows=16)
- assert metre.spans == ((4, 4, 4, 4),)
+ meter = Meter.from_settings(ProjectSettings(), rows=16)
+ assert meter.spans == ((4, 4, 4, 4),)
diff --git a/tests/unit/sampletones_core/utils/test_display.py b/tests/unit/sampletones_core/utils/test_display.py
index c342f7e13..bdf8b284a 100644
--- a/tests/unit/sampletones_core/utils/test_display.py
+++ b/tests/unit/sampletones_core/utils/test_display.py
@@ -3,19 +3,19 @@
import pytest
-from sampletones_core.constants.enums import GeneratorName
+from sampletones_core.constants.enums import ChannelName
from sampletones_core.project import Project
-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.voices.note_off import NoteOff
+from sampletones_core.project.voices.note_on import NoteOn
+from sampletones_core.project.voices.sample import Sample
from sampletones_core.utils.display import (
NOTE_BLANK,
NOTE_OFF,
display_command,
display_id,
- display_sample,
- display_sample_label,
display_transpose,
+ display_voice,
+ display_voice_label,
display_volume,
)
@@ -23,7 +23,7 @@
def _project_with_samples(count: int) -> Tuple[Project, List[Sample]]:
project = Project.create()
samples = [Sample(name=f"i{index}", reconstruction=Mock()) for index in range(count)]
- project.samples.extend(samples)
+ project.voices.extend(samples)
return project, samples
@@ -31,16 +31,16 @@ class TestDisplaySamples:
def test_present_shows_index(self) -> None:
project, samples = _project_with_samples(3)
assert (
- display_sample(
- samples=project.samples,
- sample_id=samples[0].id,
+ display_voice(
+ voices=project.voices,
+ voice_id=samples[0].id,
)
== "00"
)
assert (
- display_sample(
- samples=project.samples,
- sample_id=samples[2].id,
+ display_voice(
+ voices=project.voices,
+ voice_id=samples[2].id,
)
== "02"
)
@@ -48,16 +48,16 @@ def test_present_shows_index(self) -> None:
def test_missing_and_none_are_placeholder(self) -> None:
project, _ = _project_with_samples(1)
assert (
- display_sample(
- samples=project.samples,
- sample_id="missing",
+ display_voice(
+ voices=project.voices,
+ voice_id="missing",
)
== ".."
)
assert (
- display_sample(
- samples=project.samples,
- sample_id=None,
+ display_voice(
+ voices=project.voices,
+ voice_id=None,
)
== ".."
)
@@ -65,11 +65,11 @@ def test_missing_and_none_are_placeholder(self) -> None:
def test_index_follows_reorder(self) -> None:
project, samples = _project_with_samples(3)
first = samples[0]
- project.samples.append(project.samples.pop(0))
+ project.voices.append(project.voices.pop(0))
assert (
- display_sample(
- samples=project.samples,
- sample_id=first.id,
+ display_voice(
+ voices=project.voices,
+ voice_id=first.id,
)
== "02"
)
@@ -86,22 +86,19 @@ def test_none_is_placeholder(self) -> None:
class TestDisplaySampleLabel:
def test_combines_hex_index_and_name(self) -> None:
- assert display_sample_label(0, "Bass") == "00: Bass"
+ assert display_voice_label(0, "Bass") == "00: Bass"
def test_index_is_hexadecimal(self) -> None:
- assert display_sample_label(26, "Lead") == "1A: Lead"
+ assert display_voice_label(26, "Lead") == "1A: Lead"
class TestDisplayCommand:
def test_resolves_referenced_instrument(self) -> None:
project, samples = _project_with_samples(2)
- instrument = Instrument(
- sample_id=samples[1].id,
- generator_name=GeneratorName.PULSE1,
- )
+ instrument = NoteOn(voice_id=samples[1].id)
assert (
display_command(
- samples=project.samples,
+ voices=project.voices,
command=instrument,
)
== "01"
@@ -111,7 +108,7 @@ def test_none_is_placeholder(self) -> None:
project, _ = _project_with_samples(1)
assert (
display_command(
- samples=project.samples,
+ voices=project.voices,
command=None,
)
== ".."
@@ -121,7 +118,7 @@ def test_note_off_renders_dashes(self) -> None:
project, _ = _project_with_samples(1)
assert (
display_command(
- samples=project.samples,
+ voices=project.voices,
command=NoteOff(),
)
== NOTE_OFF
diff --git a/tests/unit/sampletones_core/utils/test_frequencies.py b/tests/unit/sampletones_core/utils/test_frequencies.py
index 8f41a2cda..50ffc17c1 100644
--- a/tests/unit/sampletones_core/utils/test_frequencies.py
+++ b/tests/unit/sampletones_core/utils/test_frequencies.py
@@ -1,242 +1,26 @@
from dataclasses import dataclass
from typing import Any, Type, Union
-import numpy as np
import pytest
-from sampletones_core.constants.general import (
- LIMIT_MAX_PITCH,
- LIMIT_MIN_PITCH,
- MAX_PERIOD,
- MAX_PITCH,
- MIN_PITCH,
-)
+from sampletones_core.constants.general import MAX_PERIOD, MAX_PITCH, MIN_PITCH
from sampletones_core.utils.frequencies import (
clamp_period,
clamp_pitch,
- frequency_to_pitch,
is_pitch_valid,
period_to_name,
- pitch_to_frequency,
pitch_to_name,
sanitize,
sanitize_period,
sanitize_pitch,
- validate_frequency,
validate_period,
- validate_pitch,
)
+from sampletones_shared.constants.music import LIMIT_MAX_PITCH, LIMIT_MIN_PITCH
from tests.suite.base import BaseTestSuite
from tests.suite.case import BaseRegularTestCase
from tests.suite.errors import expect_error
-class TestValidatePitch(BaseTestSuite):
- @dataclass(frozen=True, kw_only=True)
- class TestCase(BaseRegularTestCase):
- expected: Union[None, Type[Exception]]
- pitch: Any
-
- test_cases = (
- TestCase(
- pitch=LIMIT_MIN_PITCH,
- expected=None,
- label="exactly_min_limit",
- ),
- TestCase(
- pitch=LIMIT_MAX_PITCH,
- expected=None,
- label="exactly_max_limit",
- ),
- TestCase(
- pitch=69,
- expected=None,
- label="middle_valid_pitch",
- ),
- TestCase(
- pitch=60,
- expected=None,
- label="another_valid_pitch",
- ),
- TestCase(
- pitch=(LIMIT_MIN_PITCH + LIMIT_MAX_PITCH) // 2,
- expected=None,
- label="middle_of_range",
- ),
- TestCase(
- pitch=LIMIT_MIN_PITCH - 1,
- expected=ValueError,
- label="one_below_min",
- ),
- TestCase(
- pitch=LIMIT_MAX_PITCH + 1,
- expected=ValueError,
- label="one_above_max",
- ),
- TestCase(
- pitch=0,
- expected=ValueError,
- label="zero",
- ),
- TestCase(
- pitch=-100,
- expected=ValueError,
- label="large_negative",
- ),
- TestCase(
- pitch=200,
- expected=ValueError,
- label="large_positive",
- ),
- TestCase(
- pitch="60",
- expected=TypeError,
- label="pitch_string",
- ),
- TestCase(
- pitch=None,
- expected=TypeError,
- label="pitch_none",
- ),
- TestCase(
- pitch=60.5,
- expected=TypeError,
- label="pitch_float",
- ),
- TestCase(
- pitch=[60],
- expected=TypeError,
- label="pitch_list",
- ),
- TestCase(
- pitch={"pitch": 60},
- expected=TypeError,
- label="pitch_dict",
- ),
- )
-
- @pytest.mark.parametrize(
- "test_case",
- test_cases,
- ids=lambda test_case: test_case.label,
- )
- def test_validate_pitch(self, test_case: TestCase) -> None:
- if expect_error(validate_pitch, test_case.expected, test_case.pitch):
- return
-
- validate_pitch(test_case.pitch)
-
-
-class TestValidateFrequency:
- @dataclass(frozen=True, kw_only=True)
- class TestCase(BaseRegularTestCase):
- expected: Union[None, Type[Exception]]
- frequency: Any
-
- test_cases = (
- TestCase(
- frequency=440.0,
- expected=None,
- label="valid_float",
- ),
- TestCase(
- frequency=440,
- expected=None,
- label="valid_int",
- ),
- TestCase(
- frequency=1.0,
- expected=None,
- label="one_hz",
- ),
- TestCase(
- frequency=0.001,
- expected=None,
- label="very_small_positive",
- ),
- TestCase(
- frequency=100000.0,
- expected=None,
- label="very_large_positive",
- ),
- TestCase(
- frequency=1e-100,
- expected=None,
- label="extremely_small_positive",
- ),
- TestCase(
- frequency=1e100,
- expected=None,
- label="extremely_large_positive",
- ),
- TestCase(
- frequency=0.0,
- expected=ValueError,
- label="zero",
- ),
- TestCase(
- frequency=-1.0,
- expected=ValueError,
- label="negative",
- ),
- TestCase(
- frequency=-440.0,
- expected=ValueError,
- label="negative_440",
- ),
- TestCase(
- frequency=np.inf,
- expected=ValueError,
- label="positive_infinity",
- ),
- TestCase(
- frequency=-np.inf,
- expected=ValueError,
- label="negative_infinity",
- ),
- TestCase(
- frequency=np.nan,
- expected=ValueError,
- label="nan",
- ),
- TestCase(
- frequency="440",
- expected=TypeError,
- label="frequency_string",
- ),
- TestCase(
- frequency=None,
- expected=TypeError,
- label="frequency_none",
- ),
- TestCase(
- frequency=[440.0],
- expected=TypeError,
- label="frequency_list",
- ),
- TestCase(
- frequency={"freq": 440.0},
- expected=TypeError,
- label="frequency_dict",
- ),
- )
-
- @pytest.mark.parametrize(
- "test_case",
- test_cases,
- ids=lambda test_case: test_case.label,
- )
- def test_validate_frequency(self, test_case: TestCase) -> None:
- if expect_error(
- validate_frequency,
- test_case.expected,
- test_case.frequency,
- ):
- return
-
- validate_frequency(test_case.frequency)
-
-
class TestIsPitchValid:
def test_is_pitch_valid(self) -> None:
valid_pitches = range(MIN_PITCH, MAX_PITCH + 1)
@@ -351,464 +135,6 @@ def test_validate_period(self, test_case: TestCase) -> None:
validate_period(test_case.period)
-class TestPitchToFrequency(BaseTestSuite):
- @dataclass(frozen=True, kw_only=True)
- class TestCase(BaseRegularTestCase):
- expected: Union[float, Type[Exception]]
- pitch: Any
- a4_frequency: Any
- a4_pitch: Any
-
- test_cases = (
- TestCase(
- pitch=69,
- a4_frequency=440.0,
- a4_pitch=69,
- expected=440.0,
- label="a4_default_tuning",
- ),
- TestCase(
- pitch=81,
- a4_frequency=440.0,
- a4_pitch=69,
- expected=880.0,
- label="a5_one_octave_above",
- ),
- TestCase(
- pitch=57,
- a4_frequency=440.0,
- a4_pitch=69,
- expected=220.0,
- label="a3_one_octave_below",
- ),
- TestCase(
- pitch=60,
- a4_frequency=440.0,
- a4_pitch=69,
- expected=261.6255653005986,
- label="middle_c",
- ),
- TestCase(
- pitch=69,
- a4_frequency=432.0,
- a4_pitch=69,
- expected=432.0,
- label="a4_alternative_tuning",
- ),
- TestCase(
- pitch=69,
- a4_frequency=440.0,
- a4_pitch=69,
- expected=440.0,
- label="reference_pitch_returns_reference_frequency",
- ),
- TestCase(
- pitch=MIN_PITCH,
- a4_frequency=440.0,
- a4_pitch=69,
- expected=55.0,
- label="min_pitch_boundary",
- ),
- TestCase(
- pitch=MAX_PITCH,
- a4_frequency=440.0,
- a4_pitch=69,
- expected=7902.132820097988,
- label="max_pitch_boundary",
- ),
- TestCase(
- pitch=0,
- a4_frequency=440.0,
- a4_pitch=69,
- expected=ValueError,
- label="pitch_zero",
- ),
- TestCase(
- pitch=-12,
- a4_frequency=440.0,
- a4_pitch=69,
- expected=ValueError,
- label="negative_pitch",
- ),
- TestCase(
- pitch=127,
- a4_frequency=440.0,
- a4_pitch=69,
- expected=12543.853951415975,
- label="max_midi_pitch",
- ),
- TestCase(
- pitch=60,
- a4_frequency=432,
- a4_pitch=69,
- expected=256.86873684058776,
- label="middle_c_alternative_tuning",
- ),
- TestCase(
- pitch=60,
- a4_frequency=440.0,
- a4_pitch=60,
- expected=440.0,
- label="different_reference_pitch",
- ),
- TestCase(
- pitch=72,
- a4_frequency=440.0,
- a4_pitch=60,
- expected=880.0,
- label="octave_above_different_reference",
- ),
- TestCase(
- pitch=60,
- a4_frequency=880.0,
- a4_pitch=69,
- expected=523.2511306011972,
- label="double_reference_frequency",
- ),
- TestCase(
- pitch=69,
- a4_frequency=220.0,
- a4_pitch=69,
- expected=220.0,
- label="half_reference_frequency",
- ),
- TestCase(
- pitch="not_an_int",
- a4_frequency=440.0,
- a4_pitch=69,
- expected=TypeError,
- label="pitch_string",
- ),
- TestCase(
- pitch=None,
- a4_frequency=440.0,
- a4_pitch=69,
- expected=TypeError,
- label="pitch_none",
- ),
- TestCase(
- pitch=[60],
- a4_frequency=440.0,
- a4_pitch=69,
- expected=TypeError,
- label="pitch_list",
- ),
- TestCase(
- pitch={"pitch": 60},
- a4_frequency=440.0,
- a4_pitch=69,
- expected=TypeError,
- label="pitch_dict",
- ),
- TestCase(
- pitch=60,
- a4_frequency="440",
- a4_pitch=69,
- expected=TypeError,
- label="a4_frequency_string",
- ),
- TestCase(
- pitch=60,
- a4_frequency=None,
- a4_pitch=69,
- expected=TypeError,
- label="a4_frequency_none",
- ),
- TestCase(
- pitch=60,
- a4_frequency=[440.0],
- a4_pitch=69,
- expected=TypeError,
- label="a4_frequency_list",
- ),
- TestCase(
- pitch=60,
- a4_frequency=440.0,
- a4_pitch="69",
- expected=TypeError,
- label="a4_pitch_string",
- ),
- TestCase(
- pitch=60,
- a4_frequency=440.0,
- a4_pitch=None,
- expected=TypeError,
- label="a4_pitch_none",
- ),
- TestCase(
- pitch=60,
- a4_frequency=440.0,
- a4_pitch=[69],
- expected=TypeError,
- label="a4_pitch_list",
- ),
- TestCase(
- pitch=60,
- a4_frequency=np.inf,
- a4_pitch=69,
- expected=ValueError,
- label="a4_frequency_inf",
- ),
- TestCase(
- pitch=60,
- a4_frequency=-440.0,
- a4_pitch=69,
- expected=ValueError,
- label="a4_frequency_negative",
- ),
- TestCase(
- pitch=60,
- a4_frequency=0.0,
- a4_pitch=69,
- expected=ValueError,
- label="a4_frequency_zero",
- ),
- TestCase(
- pitch=60,
- a4_frequency=np.nan,
- a4_pitch=69,
- expected=ValueError,
- label="a4_frequency_nan",
- ),
- )
-
- @pytest.mark.parametrize(
- "test_case",
- test_cases,
- ids=lambda test_case: test_case.label,
- )
- def test_pitch_to_frequency(self, test_case: TestCase) -> None:
- if expect_error(
- pitch_to_frequency,
- test_case.expected,
- test_case.pitch,
- test_case.a4_frequency,
- test_case.a4_pitch,
- ):
- return
-
- result = pitch_to_frequency(
- test_case.pitch,
- test_case.a4_frequency,
- test_case.a4_pitch,
- )
- if isinstance(test_case.expected, float) and np.isnan(test_case.expected):
- assert np.isnan(result)
- else:
- assert result == pytest.approx(test_case.expected, rel=1e-9)
- assert isinstance(result, float)
-
-
-class TestFrequencyToPitch(BaseTestSuite):
- @dataclass(frozen=True, kw_only=True)
- class TestCase(BaseRegularTestCase):
- expected: Union[int, Type[Exception]]
- frequency: Any
- a4_frequency: Any
- a4_pitch: Any
-
- test_cases = (
- TestCase(
- frequency=440.0,
- a4_frequency=440.0,
- a4_pitch=69,
- expected=69,
- label="a4_frequency",
- ),
- TestCase(
- frequency=880.0,
- a4_frequency=440.0,
- a4_pitch=69,
- expected=81,
- label="a5_frequency",
- ),
- TestCase(
- frequency=261.63,
- a4_frequency=440.0,
- a4_pitch=69,
- expected=60,
- label="middle_c_approximate",
- ),
- TestCase(
- frequency=0.0,
- a4_frequency=440.0,
- a4_pitch=69,
- expected=ValueError,
- label="zero_frequency",
- ),
- TestCase(
- frequency=-100.0,
- a4_frequency=440.0,
- a4_pitch=69,
- expected=ValueError,
- label="negative_frequency",
- ),
- TestCase(
- frequency=220.0,
- a4_frequency=440.0,
- a4_pitch=69,
- expected=57,
- label="a3_frequency",
- ),
- TestCase(
- frequency=440.0,
- a4_frequency=440.0,
- a4_pitch=69,
- expected=69,
- label="reference_frequency_returns_reference_pitch",
- ),
- TestCase(
- frequency=55.0,
- a4_frequency=440.0,
- a4_pitch=69,
- expected=MIN_PITCH,
- label="min_frequency_boundary",
- ),
- TestCase(
- frequency=1e-10,
- a4_frequency=440.0,
- a4_pitch=69,
- expected=ValueError,
- label="very_small_positive_frequency",
- ),
- TestCase(
- frequency=100000.0,
- a4_frequency=440.0,
- a4_pitch=69,
- expected=ValueError,
- label="very_high_frequency",
- ),
- TestCase(
- frequency=432,
- a4_frequency=432,
- a4_pitch=69,
- expected=69,
- label="alternative_tuning",
- ),
- TestCase(
- frequency=440.0,
- a4_frequency=432,
- a4_pitch=69,
- expected=69,
- label="different_reference_frequency",
- ),
- TestCase(
- frequency=440,
- a4_frequency=440.0,
- a4_pitch=60,
- expected=60,
- label="different_reference_pitch",
- ),
- TestCase(
- frequency=880.0,
- a4_frequency=220.0,
- a4_pitch=69,
- expected=93,
- label="quadruple_reference_frequency",
- ),
- TestCase(
- frequency="440",
- a4_frequency=440.0,
- a4_pitch=69,
- expected=TypeError,
- label="frequency_string",
- ),
- TestCase(
- frequency=None,
- a4_frequency=440.0,
- a4_pitch=69,
- expected=TypeError,
- label="frequency_none",
- ),
- TestCase(
- frequency=[440.0],
- a4_frequency=440.0,
- a4_pitch=69,
- expected=TypeError,
- label="frequency_list",
- ),
- TestCase(
- frequency={"freq": 440.0},
- a4_frequency=440.0,
- a4_pitch=69,
- expected=TypeError,
- label="frequency_dict",
- ),
- TestCase(
- frequency=440.0,
- a4_frequency="440",
- a4_pitch=69,
- expected=TypeError,
- label="a4_frequency_string",
- ),
- TestCase(
- frequency=440.0,
- a4_frequency=None,
- a4_pitch=69,
- expected=TypeError,
- label="a4_frequency_none",
- ),
- TestCase(
- frequency=440.0,
- a4_frequency=440.0,
- a4_pitch="69",
- expected=TypeError,
- label="a4_pitch_string",
- ),
- TestCase(
- frequency=440.0,
- a4_frequency=440.0,
- a4_pitch=None,
- expected=TypeError,
- label="a4_pitch_none",
- ),
- TestCase(
- frequency=np.inf,
- a4_frequency=440.0,
- a4_pitch=69,
- expected=ValueError,
- label="frequency_inf",
- ),
- TestCase(
- frequency=-np.inf,
- a4_frequency=440.0,
- a4_pitch=69,
- expected=ValueError,
- label="frequency_negative_inf",
- ),
- TestCase(
- frequency=np.nan,
- a4_frequency=440.0,
- a4_pitch=69,
- expected=ValueError,
- label="frequency_nan",
- ),
- )
-
- @pytest.mark.parametrize(
- "test_case",
- test_cases,
- ids=lambda test_case: test_case.label,
- )
- def test_frequency_to_pitch(self, test_case: TestCase) -> None:
- if expect_error(
- frequency_to_pitch,
- test_case.expected,
- test_case.frequency,
- test_case.a4_frequency,
- test_case.a4_pitch,
- ):
- return
-
- result = frequency_to_pitch(
- test_case.frequency,
- test_case.a4_frequency,
- test_case.a4_pitch,
- )
- assert result == test_case.expected
- assert isinstance(result, int)
-
-
class TestPitchToName(BaseTestSuite):
@dataclass(frozen=True, kw_only=True)
class TestCase(BaseRegularTestCase):
diff --git a/tests/unit/sampletones_player/__init__.py b/tests/unit/sampletones_player/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/tests/unit/sampletones_player/clock/__init__.py b/tests/unit/sampletones_player/clock/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/tests/unit/sampletones_player/clock/test_schedule.py b/tests/unit/sampletones_player/clock/test_schedule.py
new file mode 100644
index 000000000..e60e8b1a7
--- /dev/null
+++ b/tests/unit/sampletones_player/clock/test_schedule.py
@@ -0,0 +1,251 @@
+from __future__ import annotations
+
+from dataclasses import dataclass
+from fractions import Fraction
+from typing import Final, Tuple
+
+import pytest
+from pydantic import ValidationError
+
+from sampletones_player.clock.schedule import PlaySchedule
+from sampletones_player.specification.clock import (
+ FIXED_POINT_BITS,
+ FIXED_POINT_SCALE,
+ MAX_STEP_WHOLE,
+ NTSC_FRAME_RATE,
+)
+from sampletones_shared.constants.nes import MAX_NES_FREQUENCY, MIN_NES_FREQUENCY
+from tests.suite.base import BaseTestSuite
+from tests.suite.case import BaseAutolabelTestCase
+
+LONG_RUN_PLAY_CALLS: Final[int] = 36000
+NES_FREQUENCIES: Final[Tuple[int, ...]] = (15, 24, 25, 30, 50, 60, 100, 120, 200, 299, 300)
+
+
+def exact_rate(nes_frequency: int) -> Fraction:
+ return Fraction(nes_frequency) / NTSC_FRAME_RATE
+
+
+class TestPlaySchedule(BaseTestSuite):
+ """One case table, read both for the advances it produces and for the rules they obey."""
+
+ @dataclass(frozen=True, kw_only=True)
+ class TestCase(BaseAutolabelTestCase):
+ expected: Tuple[int, ...]
+ nes_frequency: int
+
+ @property
+ def label(self) -> str:
+ return f"{self.nes_frequency}hz"
+
+ @property
+ def schedule(self) -> PlaySchedule:
+ return PlaySchedule.from_parameters(self.nes_frequency)
+
+ test_cases = (
+ TestCase(nes_frequency=60, expected=(0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1)),
+ TestCase(nes_frequency=30, expected=(0, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0)),
+ TestCase(nes_frequency=24, expected=(0, 0, 1, 0, 0, 1, 0, 1, 0, 0, 1, 0)),
+ TestCase(nes_frequency=15, expected=(0, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0)),
+ TestCase(nes_frequency=50, expected=(0, 1, 1, 1, 1, 0, 1, 1, 1, 1, 1, 0)),
+ TestCase(nes_frequency=100, expected=(1, 2, 1, 2, 2, 1, 2, 2, 1, 2, 2, 1)),
+ TestCase(nes_frequency=120, expected=(1, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2)),
+ TestCase(nes_frequency=300, expected=(4, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5)),
+ )
+
+ @pytest.mark.parametrize(
+ "test_case",
+ test_cases,
+ ids=lambda test_case: test_case.label,
+ )
+ def test_advances_match(self, test_case: TestCase) -> None:
+ schedule = test_case.schedule
+ advances = tuple(schedule.advance_at(play_call) for play_call in range(len(test_case.expected)))
+ assert advances == test_case.expected
+
+ @pytest.mark.parametrize(
+ "test_case",
+ test_cases,
+ ids=lambda test_case: test_case.label,
+ )
+ def test_the_advances_sum_to_the_cumulative_count(self, test_case: TestCase) -> None:
+ schedule = test_case.schedule
+ calls = len(test_case.expected)
+ assert sum(schedule.advance_at(play_call) for play_call in range(calls)) == schedule.ticks_at(calls)
+
+ @pytest.mark.parametrize(
+ "test_case",
+ test_cases,
+ ids=lambda test_case: test_case.label,
+ )
+ def test_only_the_floor_and_the_ceiling_appear(self, test_case: TestCase) -> None:
+ """Consecutive calls advance by one of two neighboring amounts, so the stream moves evenly."""
+ schedule = test_case.schedule
+ advances = {schedule.advance_at(play_call) for play_call in range(LONG_RUN_PLAY_CALLS)}
+ assert max(advances) - min(advances) <= 1
+
+ @pytest.mark.parametrize(
+ "test_case",
+ test_cases,
+ ids=lambda test_case: test_case.label,
+ )
+ def test_the_stream_only_moves_forward(self, test_case: TestCase) -> None:
+ schedule = test_case.schedule
+ assert all(schedule.advance_at(play_call) >= 0 for play_call in range(LONG_RUN_PLAY_CALLS))
+
+
+class TestTheScheduleHoldsTheRate(BaseTestSuite):
+ """The rate a stream is read at, and the advances a rate dividing the play period produces."""
+
+ @pytest.mark.parametrize("nes_frequency", NES_FREQUENCIES)
+ def test_the_rate_is_the_stream_measured_against_the_frame_rate(self, nes_frequency: int) -> None:
+ schedule = PlaySchedule.from_parameters(nes_frequency)
+ assert schedule.ticks_per_play_call == exact_rate(nes_frequency)
+
+ def test_a_stream_at_the_play_rate_advances_a_tick_a_call(self) -> None:
+ """A stream built at the rate the console calls at lands a whole tick on every call."""
+ schedule = PlaySchedule(ticks_per_play_call=Fraction(1))
+ assert all(schedule.advance_at(play_call) == 1 for play_call in range(LONG_RUN_PLAY_CALLS))
+
+ def test_a_stream_at_half_the_play_rate_alternates(self) -> None:
+ schedule = PlaySchedule(ticks_per_play_call=Fraction(1, 2))
+ advances = tuple(schedule.advance_at(play_call) for play_call in range(8))
+ assert advances == (0, 1, 0, 1, 0, 1, 0, 1)
+
+
+class TestFixedPointStep(BaseTestSuite):
+ """The step the driver carries, and how far its rounding takes the stream from the exact clock."""
+
+ @dataclass(frozen=True, kw_only=True)
+ class TestCase(BaseAutolabelTestCase):
+ expected: Tuple[int, int]
+ nes_frequency: int
+
+ @property
+ def label(self) -> str:
+ return f"{self.nes_frequency}hz"
+
+ test_cases = (
+ TestCase(nes_frequency=15, expected=(0, 16357)),
+ TestCase(nes_frequency=24, expected=(0, 26171)),
+ TestCase(nes_frequency=30, expected=(0, 32714)),
+ TestCase(nes_frequency=50, expected=(0, 54524)),
+ TestCase(nes_frequency=60, expected=(0, 65428)),
+ TestCase(nes_frequency=100, expected=(1, 43511)),
+ TestCase(nes_frequency=120, expected=(1, 65320)),
+ TestCase(nes_frequency=200, expected=(3, 21486)),
+ TestCase(nes_frequency=300, expected=(4, 64997)),
+ )
+
+ @pytest.mark.parametrize(
+ "test_case",
+ test_cases,
+ ids=lambda test_case: test_case.label,
+ )
+ def test_the_step_fields_match(self, test_case: TestCase) -> None:
+ step = PlaySchedule.from_parameters(test_case.nes_frequency).fixed_point_step
+ assert (step.whole, step.fraction) == test_case.expected
+
+ @pytest.mark.parametrize("nes_frequency", NES_FREQUENCIES)
+ def test_the_value_recomposes_the_fields(self, nes_frequency: int) -> None:
+ step = PlaySchedule.from_parameters(nes_frequency).fixed_point_step
+ assert divmod(step.value, FIXED_POINT_SCALE) == (step.whole, step.fraction)
+
+ @pytest.mark.parametrize("nes_frequency", NES_FREQUENCIES)
+ def test_the_step_is_the_nearest_unit_to_the_exact_rate(self, nes_frequency: int) -> None:
+ schedule = PlaySchedule.from_parameters(nes_frequency)
+ step = Fraction(schedule.fixed_point_step.value, FIXED_POINT_SCALE)
+ assert abs(step - schedule.ticks_per_play_call) <= Fraction(1, 2 * FIXED_POINT_SCALE)
+
+ @pytest.mark.parametrize("nes_frequency", NES_FREQUENCIES)
+ def test_the_driver_stays_within_a_tick_of_the_exact_schedule(self, nes_frequency: int) -> None:
+ """The claim the whole fixed-point step rests on, held across ten minutes of play calls."""
+ schedule = PlaySchedule.from_parameters(nes_frequency)
+ assert schedule.maximum_drift(LONG_RUN_PLAY_CALLS) <= 1
+
+ @pytest.mark.parametrize("nes_frequency", NES_FREQUENCIES)
+ def test_the_driver_starts_on_the_first_tick(self, nes_frequency: int) -> None:
+ schedule = PlaySchedule.from_parameters(nes_frequency)
+ assert schedule.ticks_at(0) == 0
+ assert schedule.maximum_drift(0) == 0
+
+ @pytest.mark.parametrize("nes_frequency", NES_FREQUENCIES)
+ def test_the_accumulator_reaches_the_same_ticks(self, nes_frequency: int) -> None:
+ """The driver's own loop, held against the schedule it is written from.
+
+ The schedule multiplies the step by the call count, and the 6502 adds the step to a
+ 16-bit accumulator once a call and reads the ticks off the carry. Both must count the
+ same, since the assembly follows the second and the golden trace follows the first.
+ """
+ schedule = PlaySchedule.from_parameters(nes_frequency)
+ step = schedule.fixed_point_step
+
+ accumulator = 0
+ for play_call in range(LONG_RUN_PLAY_CALLS):
+ accumulator += step.fraction
+ advance = step.whole + (accumulator >> FIXED_POINT_BITS)
+ accumulator %= FIXED_POINT_SCALE
+ assert advance == schedule.advance_at(play_call)
+
+ def test_a_whole_step_needs_no_fraction(self) -> None:
+ step = PlaySchedule(ticks_per_play_call=Fraction(3)).fixed_point_step
+ assert (step.whole, step.fraction, step.value) == (3, 0, 3 * FIXED_POINT_SCALE)
+
+ def test_a_step_a_hair_under_a_whole_tick_carries_into_the_whole_byte(self) -> None:
+ """Rounding the fraction up spills into the whole byte, keeping both fields in range."""
+ rate = Fraction(2) - Fraction(1, 10 * FIXED_POINT_SCALE)
+ step = PlaySchedule(ticks_per_play_call=rate).fixed_point_step
+ assert (step.whole, step.fraction) == (2, 0)
+
+ def test_the_step_is_derived_once_and_held(self) -> None:
+ schedule = PlaySchedule.from_parameters(50)
+ assert schedule.fixed_point_step is schedule.fixed_point_step
+
+
+class TestPlayScheduleBounds(BaseTestSuite):
+ def test_the_stream_starts_on_its_first_tick(self) -> None:
+ assert PlaySchedule.from_parameters(60).ticks_at(0) == 0
+
+ def test_the_schedule_stays_as_built(self) -> None:
+ schedule = PlaySchedule.from_parameters(60)
+ with pytest.raises(ValidationError):
+ schedule.ticks_per_play_call = Fraction(1)
+
+ @pytest.mark.parametrize("nes_frequency", (MIN_NES_FREQUENCY, MAX_NES_FREQUENCY))
+ def test_the_engine_range_fits_the_step(self, nes_frequency: int) -> None:
+ step = PlaySchedule.from_parameters(nes_frequency).fixed_point_step
+ assert step.whole <= MAX_STEP_WHOLE
+
+ @pytest.mark.parametrize("nes_frequency", (0, -1))
+ def test_a_tick_rate_below_one_is_rejected(self, nes_frequency: int) -> None:
+ with pytest.raises(ValueError, match="nes_frequency must be at least 1"):
+ PlaySchedule.from_parameters(nes_frequency)
+
+ @pytest.mark.parametrize("ticks_per_play_call", (Fraction(0), Fraction(-1, 2)))
+ def test_a_stream_that_never_advances_is_rejected(self, ticks_per_play_call: Fraction) -> None:
+ with pytest.raises(ValidationError, match="ticks_per_play_call"):
+ PlaySchedule(ticks_per_play_call=ticks_per_play_call)
+
+ def test_a_step_past_the_whole_byte_is_rejected(self) -> None:
+ with pytest.raises(ValidationError, match="ticks_per_play_call"):
+ PlaySchedule(ticks_per_play_call=Fraction(MAX_STEP_WHOLE + 1))
+
+ def test_a_negative_call_count_is_rejected(self) -> None:
+ schedule = PlaySchedule.from_parameters(60)
+ with pytest.raises(ValueError, match="play_calls must be at least 0"):
+ schedule.ticks_at(-1)
+
+ def test_a_negative_call_count_is_rejected_by_the_exact_schedule(self) -> None:
+ schedule = PlaySchedule.from_parameters(60)
+ with pytest.raises(ValueError, match="play_calls must be at least 0"):
+ schedule.exact_ticks_at(-1)
+
+ def test_a_negative_run_is_rejected_by_the_drift(self) -> None:
+ schedule = PlaySchedule.from_parameters(60)
+ with pytest.raises(ValueError, match="play_calls must be at least 0"):
+ schedule.maximum_drift(-1)
+
+ def test_a_negative_call_index_is_rejected(self) -> None:
+ schedule = PlaySchedule.from_parameters(60)
+ with pytest.raises(ValueError, match="play_calls must be at least 0"):
+ schedule.advance_at(-1)
diff --git a/tests/unit/sampletones_player/clock/test_step.py b/tests/unit/sampletones_player/clock/test_step.py
new file mode 100644
index 000000000..35572dbef
--- /dev/null
+++ b/tests/unit/sampletones_player/clock/test_step.py
@@ -0,0 +1,57 @@
+from dataclasses import dataclass
+from typing import Tuple
+
+import pytest
+from pydantic import ValidationError
+
+from sampletones_player.clock.step import FixedPointStep
+from sampletones_player.specification.clock import (
+ FIXED_POINT_SCALE,
+ MAX_STEP_FRACTION,
+ MAX_STEP_WHOLE,
+)
+from tests.suite.base import BaseTestSuite
+from tests.suite.case import BaseAutolabelTestCase
+
+
+class TestFixedPointStep(BaseTestSuite):
+ """The whole byte and the word the driver adds into its accumulator."""
+
+ @dataclass(frozen=True, kw_only=True)
+ class TestCase(BaseAutolabelTestCase):
+ expected: int
+ fields: Tuple[int, int]
+
+ @property
+ def label(self) -> str:
+ whole, fraction = self.fields
+ return f"{whole}-{fraction}"
+
+ test_cases = (
+ TestCase(fields=(0, 0), expected=0),
+ TestCase(fields=(0, MAX_STEP_FRACTION), expected=MAX_STEP_FRACTION),
+ TestCase(fields=(1, 0), expected=FIXED_POINT_SCALE),
+ TestCase(fields=(3, 21837), expected=3 * FIXED_POINT_SCALE + 21837),
+ TestCase(fields=(MAX_STEP_WHOLE, MAX_STEP_FRACTION), expected=FIXED_POINT_SCALE * (MAX_STEP_WHOLE + 1) - 1),
+ )
+
+ @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label)
+ def test_the_value_composes_the_fields(self, test_case: TestCase) -> None:
+ whole, fraction = test_case.fields
+ assert FixedPointStep(whole=whole, fraction=fraction).value == test_case.expected
+
+ @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label)
+ def test_the_fields_read_back_off_the_value(self, test_case: TestCase) -> None:
+ whole, fraction = test_case.fields
+ step = FixedPointStep(whole=whole, fraction=fraction)
+ assert divmod(step.value, FIXED_POINT_SCALE) == (step.whole, step.fraction)
+
+ @pytest.mark.parametrize("fraction", (-1, FIXED_POINT_SCALE))
+ def test_a_fraction_outside_the_word_is_rejected(self, fraction: int) -> None:
+ with pytest.raises(ValidationError):
+ FixedPointStep(whole=0, fraction=fraction)
+
+ @pytest.mark.parametrize("whole", (-1, MAX_STEP_WHOLE + 1))
+ def test_a_whole_part_outside_the_byte_is_rejected(self, whole: int) -> None:
+ with pytest.raises(ValidationError):
+ FixedPointStep(whole=whole, fraction=0)
diff --git a/tests/unit/sampletones_player/compression/__init__.py b/tests/unit/sampletones_player/compression/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/tests/unit/sampletones_player/compression/dictionary/__init__.py b/tests/unit/sampletones_player/compression/dictionary/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/tests/unit/sampletones_player/compression/dictionary/phrases.py b/tests/unit/sampletones_player/compression/dictionary/phrases.py
new file mode 100644
index 000000000..4142bec8c
--- /dev/null
+++ b/tests/unit/sampletones_player/compression/dictionary/phrases.py
@@ -0,0 +1,13 @@
+from typing import Tuple
+
+from sampletones_player.compression.dictionary.phrase import Phrase
+
+BODY_STRIDE: int = 0x100
+
+
+def phrase(*values: int) -> Phrase:
+ return Phrase(body=bytes(values))
+
+
+def distinct(count: int) -> Tuple[Phrase, ...]:
+ return tuple(phrase(value % BODY_STRIDE, value // BODY_STRIDE) for value in range(count))
diff --git a/tests/unit/sampletones_player/compression/dictionary/test_phrase.py b/tests/unit/sampletones_player/compression/dictionary/test_phrase.py
new file mode 100644
index 000000000..fc945a7ac
--- /dev/null
+++ b/tests/unit/sampletones_player/compression/dictionary/test_phrase.py
@@ -0,0 +1,34 @@
+import pytest
+from pydantic import ValidationError
+
+from sampletones_player.compression.dictionary.phrase import Phrase
+from sampletones_player.specification.compression import (
+ MAX_PHRASE_LENGTH,
+ PHRASE_LENGTH_SIZE,
+ PHRASE_TABLE_ENTRY_SIZE,
+)
+from tests.unit.sampletones_player.compression.dictionary.phrases import phrase
+
+
+class TestAPhraseIsAShapeRatherThanValues:
+ """What identifies a phrase is the step from each value to the next, which a shift leaves alone."""
+
+ def test_the_steps_read_the_body_pairwise(self) -> None:
+ assert phrase(10, 12, 9).differences == bytes((2, 253))
+
+ def test_a_shifted_phrase_keeps_the_steps_of_the_one_it_was_stored_from(self) -> None:
+ assert phrase(70, 72, 69).differences == phrase(10, 12, 9).differences
+
+ def test_a_phrase_covers_the_ticks_its_body_states(self) -> None:
+ assert phrase(1, 2, 3).length == 3
+
+ def test_a_phrase_costs_its_entry_its_length_and_its_body(self) -> None:
+ assert phrase(1, 2, 3).size == PHRASE_TABLE_ENTRY_SIZE + PHRASE_LENGTH_SIZE + 3
+
+ def test_a_body_reaching_past_a_length_byte_is_refused(self) -> None:
+ with pytest.raises(ValidationError):
+ Phrase(body=bytes(MAX_PHRASE_LENGTH + 1))
+
+ def test_a_phrase_covering_no_tick_is_refused(self) -> None:
+ with pytest.raises(ValidationError):
+ Phrase(body=b"")
diff --git a/tests/unit/sampletones_player/compression/dictionary/test_prune.py b/tests/unit/sampletones_player/compression/dictionary/test_prune.py
new file mode 100644
index 000000000..479ce6acd
--- /dev/null
+++ b/tests/unit/sampletones_player/compression/dictionary/test_prune.py
@@ -0,0 +1,21 @@
+from sampletones_player.compression.dictionary.prune import prune
+from sampletones_player.compression.dictionary.table import phrase_table
+from tests.unit.sampletones_player.compression.dictionary.phrases import phrase
+
+
+class TestPruningKeepsWhatPaysForItself:
+ """A phrase earns its place by sparing more bytes than its own entry takes."""
+
+ def test_a_phrase_no_token_names_is_dropped(self) -> None:
+ table = phrase_table((phrase(1, 2), phrase(3, 4)))
+ pruned = prune(table, {0: 2, 1: 0}, {0: 100, 1: 100})
+ assert pruned.phrases == (phrase(1, 2),)
+
+ def test_a_phrase_sparing_less_than_its_entry_is_dropped(self) -> None:
+ table = phrase_table((phrase(1, 2),))
+ assert prune(table, {0: 1}, {0: phrase(1, 2).size}).phrases == ()
+
+ def test_the_phrases_named_most_take_the_cheap_ids(self) -> None:
+ table = phrase_table((phrase(1, 2), phrase(3, 4)))
+ pruned = prune(table, {0: 1, 1: 9}, {0: 100, 1: 100})
+ assert pruned.phrases == (phrase(3, 4), phrase(1, 2))
diff --git a/tests/unit/sampletones_player/compression/dictionary/test_table.py b/tests/unit/sampletones_player/compression/dictionary/test_table.py
new file mode 100644
index 000000000..ef6727a1a
--- /dev/null
+++ b/tests/unit/sampletones_player/compression/dictionary/test_table.py
@@ -0,0 +1,32 @@
+import pytest
+from pydantic import ValidationError
+
+from sampletones_player.compression.dictionary.table import PhraseTable, phrase_table
+from sampletones_player.specification.compression import (
+ MAX_PHRASE_IDS,
+ PHRASE_TABLE_COUNT_SIZE,
+)
+from tests.unit.sampletones_player.compression.dictionary.phrases import distinct, phrase
+
+
+class TestTheTableHoldsWhatATokenCanName:
+ """A phrase's position is its id, and the cheap ids ride inside an opcode."""
+
+ def test_a_shape_offered_twice_is_held_once(self) -> None:
+ table = phrase_table((phrase(1, 2), phrase(1, 2), phrase(3, 4)))
+ assert table.phrases == (phrase(1, 2), phrase(3, 4))
+
+ def test_a_phrase_is_reached_by_the_id_its_position_gives_it(self) -> None:
+ table = phrase_table((phrase(1, 2), phrase(3, 4)))
+ assert table[1] == phrase(3, 4)
+
+ def test_the_table_stops_where_a_token_stops_naming(self) -> None:
+ assert len(phrase_table(distinct(MAX_PHRASE_IDS + 10))) == MAX_PHRASE_IDS
+
+ def test_a_table_beyond_the_ids_a_token_reaches_is_refused(self) -> None:
+ with pytest.raises(ValidationError):
+ PhraseTable(phrases=tuple(distinct(MAX_PHRASE_IDS + 1)))
+
+ def test_the_dictionary_costs_its_count_and_its_phrases(self) -> None:
+ table = phrase_table((phrase(1, 2), phrase(3, 4, 5)))
+ assert table.size == PHRASE_TABLE_COUNT_SIZE + phrase(1, 2).size + phrase(3, 4, 5).size
diff --git a/tests/unit/sampletones_player/compression/matches/__init__.py b/tests/unit/sampletones_player/compression/matches/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/tests/unit/sampletones_player/compression/matches/test_index.py b/tests/unit/sampletones_player/compression/matches/test_index.py
new file mode 100644
index 000000000..29e8df41b
--- /dev/null
+++ b/tests/unit/sampletones_player/compression/matches/test_index.py
@@ -0,0 +1,16 @@
+from sampletones_player.compression.matches.index import PlaneIndex
+
+
+class TestAPlaneIsReadAsStepsAndRuns:
+ """Matching is decided against the steps between values and how far each value carries."""
+
+ def test_the_steps_read_the_plane_pairwise(self) -> None:
+ assert PlaneIndex.from_plane(bytes((3, 5, 2))).differences == bytes((2, 253))
+
+ def test_a_run_counts_the_ticks_its_value_holds_for(self) -> None:
+ assert PlaneIndex.from_plane(bytes((7, 7, 7, 9))).runs == (3, 2, 1, 1)
+
+ def test_a_single_tick_carries_no_steps(self) -> None:
+ index = PlaneIndex.from_plane(bytes((5,)))
+ assert index.differences == b""
+ assert index.ticks == 1
diff --git a/tests/unit/sampletones_player/compression/matches/test_matcher.py b/tests/unit/sampletones_player/compression/matches/test_matcher.py
new file mode 100644
index 000000000..f35ca0764
--- /dev/null
+++ b/tests/unit/sampletones_player/compression/matches/test_matcher.py
@@ -0,0 +1,61 @@
+from typing import Final, List, Tuple
+
+from sampletones_player.compression.dictionary.phrase import Phrase
+from sampletones_player.compression.dictionary.table import phrase_table
+from sampletones_player.compression.matches.cache import KEY_LENGTH, MatchCache
+from sampletones_player.compression.matches.index import PlaneIndex
+from sampletones_player.compression.matches.match import PhraseMatch
+from sampletones_player.compression.matches.matcher import PhraseMatcher
+from sampletones_player.specification.compression import MAX_PHRASE_TICKS
+
+MOTIF: Final[bytes] = bytes((40, 44, 47))
+SINGLE_PLANE: Final[int] = 0
+
+
+def found(plane: bytes, phrases: Tuple[Phrase, ...], position: int) -> List[PhraseMatch]:
+ cache = MatchCache((PlaneIndex.from_plane(plane),))
+ matcher = PhraseMatcher(phrase_table(phrases), SINGLE_PLANE, cache)
+ return list(
+ matcher.matches(
+ position,
+ min(MAX_PHRASE_TICKS, len(plane) - position),
+ transposition=True,
+ )
+ )
+
+
+class TestWhichPhrasesAPlanePlays:
+ """One dictionary entry serves every pitch and every length a figure is played at."""
+
+ def test_a_phrase_matches_where_the_plane_plays_it(self) -> None:
+ assert found(MOTIF, (Phrase(body=MOTIF),), 0) == [PhraseMatch(phrase_id=0, ticks=len(MOTIF), transpose=0)]
+
+ def test_a_phrase_matches_the_same_figure_played_higher(self) -> None:
+ higher = bytes(value + 5 for value in MOTIF)
+ assert found(higher, (Phrase(body=MOTIF),), 0) == [PhraseMatch(phrase_id=0, ticks=len(MOTIF), transpose=5)]
+
+ def test_a_phrase_matches_the_same_figure_played_lower(self) -> None:
+ """A shift is added to a byte, so a fall reaches the token as the byte that wraps to it."""
+ lower = bytes(value - 3 for value in MOTIF)
+ assert found(lower, (Phrase(body=MOTIF),), 0) == [PhraseMatch(phrase_id=0, ticks=len(MOTIF), transpose=253)]
+
+ def test_a_note_outlasting_its_phrase_holds_the_phrase_out(self) -> None:
+ plane = MOTIF + bytes((MOTIF[-1],)) * 4
+ assert found(plane, (Phrase(body=MOTIF),), 0) == [PhraseMatch(phrase_id=0, ticks=len(plane), transpose=0)]
+
+ def test_a_note_cut_short_plays_as_much_of_the_phrase_as_sounded(self) -> None:
+ plane = MOTIF[:3] + bytes((99,))
+ assert found(plane, (Phrase(body=MOTIF),), 0) == [PhraseMatch(phrase_id=0, ticks=3, transpose=0)]
+
+ def test_a_note_cut_before_its_shape_is_told_apart_is_offered_nowhere(self) -> None:
+ """A phrase is shortlisted by its first steps, so a shorter start names no phrase."""
+ plane = MOTIF[:KEY_LENGTH] + bytes((99,))
+ assert found(plane, (Phrase(body=MOTIF),), 0) == []
+
+ def test_a_figure_the_plane_never_plays_is_offered_nowhere(self) -> None:
+ assert found(bytes((1, 9, 1)), (Phrase(body=MOTIF),), 0) == []
+
+ def test_a_phrase_is_offered_wherever_the_plane_plays_it(self) -> None:
+ plane = MOTIF + bytes((0,)) + MOTIF
+ entered = found(plane, (Phrase(body=MOTIF),), len(MOTIF) + 1)
+ assert entered == [PhraseMatch(phrase_id=0, ticks=len(MOTIF), transpose=0)]
diff --git a/tests/unit/sampletones_player/compression/matches/test_shift.py b/tests/unit/sampletones_player/compression/matches/test_shift.py
new file mode 100644
index 000000000..9b4d3afea
--- /dev/null
+++ b/tests/unit/sampletones_player/compression/matches/test_shift.py
@@ -0,0 +1,17 @@
+from sampletones_player.compression.matches.shift import translation
+from sampletones_player.specification.binary import BYTE_VALUES
+
+MOTIF: bytes = bytes((40, 44, 47))
+
+
+class TestAShiftMovesEveryValueOfAPhrase:
+ """The driver adds the shift to a byte, so the encoder agrees with it byte for byte."""
+
+ def test_a_rise_moves_every_value_up(self) -> None:
+ assert MOTIF.translate(translation(5)) == bytes(value + 5 for value in MOTIF)
+
+ def test_a_fall_reaches_a_phrase_as_the_byte_that_wraps_to_it(self) -> None:
+ assert MOTIF.translate(translation(BYTE_VALUES - 3)) == bytes(value - 3 for value in MOTIF)
+
+ def test_the_shift_a_phrase_is_stored_at_leaves_it_alone(self) -> None:
+ assert MOTIF.translate(translation(0)) == MOTIF
diff --git a/tests/unit/sampletones_player/compression/parse/__init__.py b/tests/unit/sampletones_player/compression/parse/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/tests/unit/sampletones_player/compression/parse/test_boundaries.py b/tests/unit/sampletones_player/compression/parse/test_boundaries.py
new file mode 100644
index 000000000..d9b7dc1f3
--- /dev/null
+++ b/tests/unit/sampletones_player/compression/parse/test_boundaries.py
@@ -0,0 +1,29 @@
+from typing import FrozenSet
+
+from sampletones_player.compression.parse.boundaries import Boundaries
+
+TICKS: int = 10
+ENTRIES: FrozenSet[int] = frozenset({0, 4})
+
+
+class TestWhereATokenMayStartAndHowFarItReaches:
+ """A loop re-enters the stream partway through, so nothing spans the tick it re-enters at."""
+
+ def test_the_entries_are_the_ticks_a_token_starts_on(self) -> None:
+ assert Boundaries.across(TICKS, ENTRIES).entries == ENTRIES
+
+ def test_a_tick_looks_back_to_the_boundary_behind_it(self) -> None:
+ boundaries = Boundaries.across(TICKS, ENTRIES)
+ assert boundaries.previous[6] == 4
+
+ def test_a_tick_before_any_boundary_looks_back_to_the_start(self) -> None:
+ boundaries = Boundaries.across(TICKS, ENTRIES)
+ assert boundaries.previous[3] == 0
+
+ def test_a_tick_looks_forward_to_the_boundary_ahead_of_it(self) -> None:
+ boundaries = Boundaries.across(TICKS, ENTRIES)
+ assert boundaries.following[0] == 4
+
+ def test_a_tick_past_the_last_boundary_looks_forward_to_the_end_of_the_plane(self) -> None:
+ boundaries = Boundaries.across(TICKS, ENTRIES)
+ assert boundaries.following[4] == TICKS
diff --git a/tests/unit/sampletones_player/compression/parse/test_plane.py b/tests/unit/sampletones_player/compression/parse/test_plane.py
new file mode 100644
index 000000000..ebbadd425
--- /dev/null
+++ b/tests/unit/sampletones_player/compression/parse/test_plane.py
@@ -0,0 +1,118 @@
+from typing import Final, FrozenSet, Tuple
+
+from sampletones_player.compression.dictionary.phrase import Phrase
+from sampletones_player.compression.dictionary.table import phrase_table
+from sampletones_player.compression.matches.cache import MatchCache
+from sampletones_player.compression.matches.index import PlaneIndex
+from sampletones_player.compression.matches.matcher import PhraseMatcher
+from sampletones_player.compression.options import CodecOptions
+from sampletones_player.compression.parse.plane import parse_plane
+from sampletones_player.compression.parse.result import Parse
+from sampletones_player.compression.tokens.hold import HoldToken
+from sampletones_player.compression.tokens.literal import LiteralToken
+from sampletones_player.compression.tokens.phrase import PhraseToken
+from sampletones_player.compression.tokens.types import TokenUnion
+from sampletones_player.specification.compression import MAX_HOLD_TICKS, MAX_LITERAL_BYTES
+
+EVERY_LAYER: Final[CodecOptions] = CodecOptions(
+ holds=True,
+ phrases=True,
+ transposition=True,
+ search=False,
+)
+LITERALS_ONLY: Final[CodecOptions] = CodecOptions(
+ holds=False,
+ phrases=False,
+ transposition=False,
+ search=False,
+)
+START: Final[FrozenSet[int]] = frozenset({0})
+MOTIF: Final[bytes] = bytes((40, 44, 47, 44))
+SINGLE_PLANE: Final[int] = 0
+
+
+def parsed(
+ plane: bytes,
+ phrases: Tuple[Phrase, ...] = (),
+ options: CodecOptions = EVERY_LAYER,
+ boundaries: FrozenSet[int] = START,
+) -> Parse:
+ cache = MatchCache((PlaneIndex.from_plane(plane),))
+ return parse_plane(
+ PhraseMatcher(phrase_table(phrases), SINGLE_PLANE, cache),
+ options,
+ boundaries,
+ )
+
+
+def starts(parse: Parse) -> Tuple[int, ...]:
+ positions = []
+ position = 0
+ for token in parse.tokens:
+ positions.append(position)
+ position += token.ticks
+
+ return tuple(positions)
+
+
+class TestTheParseCoversThePlaneCheaply:
+ """Every way of covering a tick is an edge, and the encoding is the cheapest path across them."""
+
+ def test_the_tokens_cover_every_tick_of_the_plane(self) -> None:
+ plane = bytes((1, 1, 1, 2, 3, 3))
+ parse = parsed(plane)
+ assert sum(token.ticks for token in parse.tokens) == len(plane)
+
+ def test_the_cost_of_the_whole_plane_is_the_cost_of_its_tokens(self) -> None:
+ parse = parsed(bytes((1, 1, 2, 2, 2, 9)))
+ assert parse.size == sum(token.size for token in parse.tokens)
+
+ def test_a_run_reaches_the_stream_as_a_hold(self) -> None:
+ parse = parsed(bytes((7,)) * 40)
+ assert parse.tokens == (LiteralToken(values=bytes((7,))), HoldToken(ticks=39))
+
+ def test_a_run_longer_than_one_hold_reaches_the_stream_as_several(self) -> None:
+ parse = parsed(bytes((7,)) * (2 * MAX_HOLD_TICKS + 1))
+ assert parse.tokens[1:] == (HoldToken(ticks=MAX_HOLD_TICKS),) * 2
+
+ def test_a_plane_the_codec_finds_nothing_in_spells_itself_out(self) -> None:
+ plane = bytes(range(MAX_LITERAL_BYTES + 4))
+ parse = parsed(plane, options=LITERALS_ONLY)
+ assert parse.tokens == (
+ LiteralToken(values=plane[:MAX_LITERAL_BYTES]),
+ LiteralToken(values=plane[MAX_LITERAL_BYTES:]),
+ )
+
+ def test_a_figure_the_dictionary_holds_reaches_the_stream_as_a_phrase(self) -> None:
+ parse = parsed(MOTIF, (Phrase(body=MOTIF),))
+ assert parse.tokens == (PhraseToken(phrase_id=0, ticks=len(MOTIF), transpose=0),)
+
+ def test_the_same_figure_played_higher_names_the_same_phrase(self) -> None:
+ higher = bytes(value + 7 for value in MOTIF)
+ parse = parsed(higher, (Phrase(body=MOTIF),))
+ assert parse.tokens == (PhraseToken(phrase_id=0, ticks=len(MOTIF), transpose=7),)
+
+ def test_a_phrase_the_layer_switches_off_is_spelled_out_instead(self) -> None:
+ parse = parsed(MOTIF, (Phrase(body=MOTIF),), options=LITERALS_ONLY)
+ assert parse.tokens == (LiteralToken(values=MOTIF),)
+
+
+class TestABoundaryIsATickATokenStartsOn:
+ """A loop entry is re-entered mid-stream, so a token starts there and leans on nothing before it."""
+
+ def test_a_token_starts_on_every_boundary(self) -> None:
+ plane = bytes((3,)) * 20
+ parse = parsed(plane, boundaries=frozenset({0, 7, 13}))
+ assert {0, 7, 13} <= set(starts(parse))
+
+ def test_a_boundary_is_reached_by_a_token_stating_its_own_value(self) -> None:
+ """A hold plays the value the plane already reached, which a re-entry has yet to state."""
+ plane = bytes((3,)) * 20
+ parse = parsed(plane, boundaries=frozenset({0, 7}))
+ entered = parse.tokens[starts(parse).index(7)]
+ assert isinstance(entered, LiteralToken)
+
+ def test_the_plane_still_reads_back_the_same_ticks(self) -> None:
+ plane = bytes((3,)) * 9 + bytes((4,)) * 9
+ bounded: Tuple[TokenUnion, ...] = parsed(plane, boundaries=frozenset({0, 5, 12})).tokens
+ assert sum(token.ticks for token in bounded) == len(plane)
diff --git a/tests/unit/sampletones_player/compression/planes/__init__.py b/tests/unit/sampletones_player/compression/planes/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/tests/unit/sampletones_player/compression/planes/conftest.py b/tests/unit/sampletones_player/compression/planes/conftest.py
new file mode 100644
index 000000000..3019f5a9d
--- /dev/null
+++ b/tests/unit/sampletones_player/compression/planes/conftest.py
@@ -0,0 +1,43 @@
+from typing import Final
+
+import pytest
+
+from sampletones_player.compression.pitch import PitchTable
+from sampletones_player.registers.streams import ChannelStreams
+from sampletones_shared.music import Tuning
+from tests.suite.player import (
+ PLAYER_FULL_VOLUME,
+ PLAYER_SILENT_VOLUME,
+ noise_tick,
+ player_streams,
+ pulse_tick,
+ triangle_tick,
+)
+
+TUNING: Final[Tuning] = Tuning()
+PITCHES: Final[PitchTable] = PitchTable.from_tuning(TUNING)
+LOW_INDEX: Final[int] = 36
+HIGH_INDEX: Final[int] = 48
+NOISE_PERIOD: Final[int] = 0x0A
+SOUNDING_TICKS: Final[int] = 3
+
+
+@pytest.fixture
+def pitches() -> PitchTable:
+ """The timer each pitch sounds at, at the tuning the fixture's streams were written under."""
+ return PITCHES
+
+
+@pytest.fixture
+def sounding_streams() -> ChannelStreams:
+ """Four channels sounding at once, the pulse channel walking and the rest holding still."""
+ return player_streams(
+ pulse1=(
+ pulse_tick(PLAYER_FULL_VOLUME, 0, PITCHES.timers[LOW_INDEX]),
+ pulse_tick(PLAYER_FULL_VOLUME, 1, PITCHES.timers[HIGH_INDEX]),
+ pulse_tick(PLAYER_SILENT_VOLUME, 1, PITCHES.timers[HIGH_INDEX]),
+ ),
+ pulse2=(pulse_tick(PLAYER_SILENT_VOLUME, 0, PITCHES.timers[LOW_INDEX]),),
+ triangle=(triangle_tick(True, PITCHES.timers[LOW_INDEX]),),
+ noise=(noise_tick(PLAYER_FULL_VOLUME, 0, NOISE_PERIOD),),
+ )
diff --git a/tests/unit/sampletones_player/compression/planes/test_channel.py b/tests/unit/sampletones_player/compression/planes/test_channel.py
new file mode 100644
index 000000000..7db19099d
--- /dev/null
+++ b/tests/unit/sampletones_player/compression/planes/test_channel.py
@@ -0,0 +1,21 @@
+import pytest
+from pydantic import ValidationError
+
+from sampletones_player.compression.planes.channel import ChannelPlanes
+
+
+class TestAChannelWritesTwoPlanesOfEqualLength:
+ """The planes are read tick for tick, so a channel states them all across the same ticks."""
+
+ def test_both_planes_reach_the_ticks_the_channel_covers(self) -> None:
+ planes = ChannelPlanes(control=bytes(4), value=bytes(4))
+ assert planes.ticks == 4
+ assert planes.ordered == (planes.control, planes.value)
+
+ def test_planes_covering_different_ticks_are_refused(self) -> None:
+ with pytest.raises(ValidationError):
+ ChannelPlanes(control=bytes(3), value=bytes(2))
+
+ def test_planes_covering_no_tick_are_refused(self) -> None:
+ with pytest.raises(ValidationError):
+ ChannelPlanes(control=b"", value=b"")
diff --git a/tests/unit/sampletones_player/compression/planes/test_order.py b/tests/unit/sampletones_player/compression/planes/test_order.py
new file mode 100644
index 000000000..571c9331c
--- /dev/null
+++ b/tests/unit/sampletones_player/compression/planes/test_order.py
@@ -0,0 +1,21 @@
+import pytest
+
+from sampletones_player.compression.planes.order import PlaneOrder
+from sampletones_player.specification.compression import PLANE_COUNT
+
+
+def numbered(count: int) -> PlaneOrder:
+ return PlaneOrder.across(bytes((plane,)) for plane in range(count))
+
+
+class TestThePlanesAreNamedRatherThanNumbered:
+ """The song block writes its planes in one order, and each is reached by its own name."""
+
+ def test_the_planes_take_the_names_the_song_block_writes_them_by(self) -> None:
+ planes = numbered(PLANE_COUNT)
+ assert planes.pulse1_control == bytes((0,))
+ assert planes.noise_value == bytes((PLANE_COUNT - 1,))
+
+ def test_a_run_of_planes_other_than_a_song_block_holds_is_refused(self) -> None:
+ with pytest.raises(ValueError):
+ numbered(PLANE_COUNT - 1)
diff --git a/tests/unit/sampletones_player/compression/planes/test_rebuild.py b/tests/unit/sampletones_player/compression/planes/test_rebuild.py
new file mode 100644
index 000000000..bbe22ccf7
--- /dev/null
+++ b/tests/unit/sampletones_player/compression/planes/test_rebuild.py
@@ -0,0 +1,18 @@
+from sampletones_player.compression.pitch import PitchTable
+from sampletones_player.compression.planes.rebuild import streams_from_planes
+from sampletones_player.compression.planes.separate import planes_from_streams
+from sampletones_player.registers.streams import ChannelStreams
+
+
+class TestThePlanesReadBackAsTheStreamsTheyCameFrom:
+ """The separation is a reading of the streams, so it carries every register value."""
+
+ def test_a_song_rebuilds_from_its_planes(
+ self,
+ sounding_streams: ChannelStreams,
+ pitches: PitchTable,
+ ) -> None:
+ planes = planes_from_streams(sounding_streams, pitches)
+ rebuilt = streams_from_planes(planes, pitches)
+ for tick in range(sounding_streams.ticks):
+ assert rebuilt.at(tick) == sounding_streams.at(tick)
diff --git a/tests/unit/sampletones_player/compression/planes/test_separate.py b/tests/unit/sampletones_player/compression/planes/test_separate.py
new file mode 100644
index 000000000..3830bf91b
--- /dev/null
+++ b/tests/unit/sampletones_player/compression/planes/test_separate.py
@@ -0,0 +1,48 @@
+from sampletones_core.constants.enums import ChannelName
+from sampletones_player.compression.pitch import PitchTable
+from sampletones_player.compression.planes.separate import channel_planes, planes_from_streams
+from sampletones_player.registers.streams import ChannelStreams
+from tests.unit.sampletones_player.compression.planes.conftest import (
+ HIGH_INDEX,
+ LOW_INDEX,
+ NOISE_PERIOD,
+ SOUNDING_TICKS,
+)
+
+
+class TestAChannelSeparatesIntoTwoPlanes:
+ """A channel writes how it sounds and what it sounds, and each turns over at its own pace."""
+
+ def test_a_tone_channel_names_its_pitch_rather_than_its_divider(
+ self,
+ sounding_streams: ChannelStreams,
+ pitches: PitchTable,
+ ) -> None:
+ planes = planes_from_streams(sounding_streams, pitches)
+ assert planes.pulse1.value == bytes((LOW_INDEX, HIGH_INDEX, HIGH_INDEX))
+
+ def test_the_noise_channel_names_the_period_its_register_takes(
+ self,
+ sounding_streams: ChannelStreams,
+ pitches: PitchTable,
+ ) -> None:
+ planes = planes_from_streams(sounding_streams, pitches)
+ assert planes.noise.value == bytes((NOISE_PERIOD,)) * planes.ticks
+
+ def test_a_channel_running_out_early_holds_its_values_through_the_song(
+ self,
+ sounding_streams: ChannelStreams,
+ pitches: PitchTable,
+ ) -> None:
+ planes = planes_from_streams(sounding_streams, pitches)
+ assert planes.ticks == SOUNDING_TICKS
+ assert planes.pulse2.control == bytes((planes.pulse2.control[0],)) * SOUNDING_TICKS
+
+ def test_one_channel_separates_the_same_way_the_song_does(
+ self,
+ sounding_streams: ChannelStreams,
+ pitches: PitchTable,
+ ) -> None:
+ planes = planes_from_streams(sounding_streams, pitches)
+ pulse1 = channel_planes(ChannelName.PULSE1, sounding_streams.padded[0], pitches)
+ assert pulse1 == planes.pulse1
diff --git a/tests/unit/sampletones_player/compression/planes/test_song.py b/tests/unit/sampletones_player/compression/planes/test_song.py
new file mode 100644
index 000000000..bbf8b3cb7
--- /dev/null
+++ b/tests/unit/sampletones_player/compression/planes/test_song.py
@@ -0,0 +1,50 @@
+from typing import Final
+
+import pytest
+from pydantic import ValidationError
+
+from sampletones_player.compression.planes.channel import ChannelPlanes, TonePlanes
+from sampletones_player.compression.planes.song import SongPlanes
+from sampletones_player.specification.compression import PLANE_COUNT
+
+BEND: Final[bytes] = bytes((0x00, 0xFD))
+
+
+def song(control: bytes, value: bytes) -> SongPlanes:
+ channel = TonePlanes(control=control, value=value, bend=BEND)
+ resting = TonePlanes(
+ control=bytes(len(control)),
+ value=bytes(len(value)),
+ bend=bytes(len(control)),
+ )
+ silent = ChannelPlanes(control=bytes(len(control)), value=bytes(len(value)))
+ return SongPlanes(pulse1=channel, pulse2=resting, triangle=resting, noise=silent)
+
+
+class TestASongGathersItsChannelsPlanes:
+ """Every plane advances beside the rest, so the song states them all under one length."""
+
+ def test_a_song_carries_the_planes_a_block_writes(self) -> None:
+ assert len(song(bytes((1, 2)), bytes((3, 4))).planes) == PLANE_COUNT
+
+ def test_the_planes_read_back_under_the_channels_that_write_them(self) -> None:
+ planes = song(bytes((1, 2)), bytes((3, 4)))
+ assert SongPlanes.from_order(planes.planes) == planes
+
+ def test_a_tone_channels_bend_reaches_the_block_beside_its_pitch(self) -> None:
+ planes = song(bytes((1, 2)), bytes((3, 4))).planes
+ assert planes.pulse1_bend == BEND
+
+ def test_a_song_lasts_the_ticks_its_channels_cover(self) -> None:
+ assert song(bytes((1, 2)), bytes((3, 4))).ticks == 2
+
+ def test_channels_covering_different_ticks_are_refused(self) -> None:
+ short = TonePlanes(control=bytes(1), value=bytes(1), bend=bytes(1))
+ long = TonePlanes(control=bytes(2), value=bytes(2), bend=bytes(2))
+ with pytest.raises(ValidationError):
+ SongPlanes(
+ pulse1=short,
+ pulse2=long,
+ triangle=short,
+ noise=ChannelPlanes(control=bytes(1), value=bytes(1)),
+ )
diff --git a/tests/unit/sampletones_player/compression/progress/__init__.py b/tests/unit/sampletones_player/compression/progress/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/tests/unit/sampletones_player/compression/progress/test_monitor.py b/tests/unit/sampletones_player/compression/progress/test_monitor.py
new file mode 100644
index 000000000..99585392a
--- /dev/null
+++ b/tests/unit/sampletones_player/compression/progress/test_monitor.py
@@ -0,0 +1,61 @@
+from typing import Final
+
+import pytest
+
+from sampletones_player.compression.progress.monitor import CodecMonitor
+from sampletones_player.compression.progress.report import (
+ CodecProgress,
+)
+from sampletones_shared.exceptions import OperationCanceled
+from sampletones_shared.utils.progress import silent_reporter
+from tests.suite.progress import FIRST_REPORT, RecordingReporter
+
+PHRASES_FOUND: Final[int] = 4
+BYTES_LAID_DOWN: Final[int] = 812
+NOTHING: Final[int] = 0
+
+
+class TestWhatARunSaysAboutItself:
+ """The monitor carries the codec's own reckoning out to whoever asked for the run."""
+
+ def test_a_reading_of_the_whole_song_reaches_the_caller(self) -> None:
+ reporter: RecordingReporter[CodecProgress] = RecordingReporter()
+ CodecMonitor(reporter).reached(PHRASES_FOUND, BYTES_LAID_DOWN)
+ assert reporter.last == CodecProgress(phrases=PHRASES_FOUND, size=BYTES_LAID_DOWN)
+
+ def test_a_run_that_has_read_nothing_yet_says_so(self) -> None:
+ reporter: RecordingReporter[CodecProgress] = RecordingReporter()
+ CodecMonitor(reporter).poll()
+ assert reporter.last == CodecProgress(phrases=NOTHING, size=NOTHING)
+
+ def test_a_stretch_between_readings_repeats_the_last_one(self) -> None:
+ """Reading a plane says nothing new about the song, and still has to be heard."""
+ reporter: RecordingReporter[CodecProgress] = RecordingReporter()
+ monitor = CodecMonitor(reporter)
+ monitor.reached(PHRASES_FOUND, BYTES_LAID_DOWN)
+ monitor.poll()
+ assert reporter.last == CodecProgress(phrases=PHRASES_FOUND, size=BYTES_LAID_DOWN)
+
+ def test_what_the_run_last_reached_is_its_own_to_read(self) -> None:
+ monitor = CodecMonitor(silent_reporter)
+ monitor.reached(PHRASES_FOUND, BYTES_LAID_DOWN)
+ assert monitor.progress == CodecProgress(phrases=PHRASES_FOUND, size=BYTES_LAID_DOWN)
+
+
+class TestWithdrawingARun:
+ """A caller that stops wanting the compression stops the compression."""
+
+ def test_a_withdrawn_reading_unwinds_the_run(self) -> None:
+ reporter: RecordingReporter[CodecProgress] = RecordingReporter(withdraw_at=FIRST_REPORT)
+ with pytest.raises(OperationCanceled):
+ CodecMonitor(reporter).reached(PHRASES_FOUND, BYTES_LAID_DOWN)
+
+ def test_a_withdrawn_poll_unwinds_the_run(self) -> None:
+ reporter: RecordingReporter[CodecProgress] = RecordingReporter(withdraw_at=FIRST_REPORT)
+ with pytest.raises(OperationCanceled):
+ CodecMonitor(reporter).poll()
+
+ def test_a_withdrawal_names_what_the_run_was_holding(self) -> None:
+ reporter: RecordingReporter[CodecProgress] = RecordingReporter(withdraw_at=FIRST_REPORT)
+ with pytest.raises(OperationCanceled, match=str(BYTES_LAID_DOWN)):
+ CodecMonitor(reporter).reached(PHRASES_FOUND, BYTES_LAID_DOWN)
diff --git a/tests/unit/sampletones_player/compression/test_admit.py b/tests/unit/sampletones_player/compression/test_admit.py
new file mode 100644
index 000000000..55f9d374c
--- /dev/null
+++ b/tests/unit/sampletones_player/compression/test_admit.py
@@ -0,0 +1,126 @@
+import logging
+from dataclasses import replace
+from typing import Final, List, Tuple
+
+import pytest
+
+from sampletones_player.compression.admit import admit_seeds
+from sampletones_player.compression.dictionary.phrase import Phrase
+from sampletones_player.compression.dictionary.table import phrase_table
+from sampletones_player.compression.matches.cache import MatchCache
+from sampletones_player.compression.matches.index import PlaneIndex
+from sampletones_player.compression.options import EVERY_LAYER
+from sampletones_player.compression.parse.result import Parse
+from sampletones_player.compression.parse.song import parse_planes
+from sampletones_player.compression.progress.monitor import CodecMonitor
+from sampletones_player.specification.binary import BYTE_VALUES
+from sampletones_player.specification.compression import MAX_PHRASE_IDS
+from sampletones_shared.utils.progress import silent_reporter
+
+STREAM_START: Final[frozenset] = frozenset({0})
+LEANED_ON: Final[bytes] = b"\x10\x18\x14\x22\x1c\x30\x11\x19\x15\x23\x1d\x31\x12\x1a\x16\x24"
+PLAYED_SELDOM: Final[bytes] = b"\x60\x63\x67\x6c\x72\x79\x61\x64\x68\x6d\x73\x7a\x62\x65\x69\x6e"
+LEANED_ON_REPEATS: Final[int] = 12
+SELDOM_REPEATS: Final[int] = 4
+RESTING_TICKS: Final[int] = 20
+UNPLAYED_STEPS: Final[Tuple[int, ...]] = (0x40, 0x50)
+UNPLAYED_PER_STEP: Final[int] = 160
+UNRELATED: Final[bytes] = b"\x77\x77\x77"
+
+PLANE: Final[bytes] = LEANED_ON * LEANED_ON_REPEATS + PLAYED_SELDOM * SELDOM_REPEATS + bytes(RESTING_TICKS)
+
+
+def unplayed_seeds() -> Tuple[Phrase, ...]:
+ """Phrases whose shape the plane holds nowhere, so none of them pays anything."""
+ seeds: List[Phrase] = []
+ for step in UNPLAYED_STEPS:
+ for value in range(UNPLAYED_PER_STEP):
+ seeds.append(
+ Phrase(
+ body=bytes(
+ [
+ value,
+ (value + step) % BYTE_VALUES,
+ (value + 2 * step) % BYTE_VALUES,
+ ]
+ )
+ )
+ )
+
+ return tuple(seeds)
+
+
+@pytest.fixture(name="cache")
+def cache_fixture() -> MatchCache:
+ return MatchCache([PlaneIndex.from_plane(PLANE)])
+
+
+@pytest.fixture(name="baseline")
+def baseline_fixture(cache: MatchCache) -> Tuple[Parse, ...]:
+ """The reading the plane takes when its tokens name no phrase at all."""
+ return parse_planes(
+ cache,
+ phrase_table(()),
+ replace(EVERY_LAYER, phrases=False),
+ STREAM_START,
+ CodecMonitor(silent_reporter),
+ )
+
+
+class TestSeedsTheDictionaryHasRoomFor:
+ """Where every seed fits, every seed is offered and the settling decides what stays."""
+
+ def test_every_seed_is_taken(self, cache: MatchCache, baseline: Tuple[Parse, ...]) -> None:
+ seeds = (Phrase(body=LEANED_ON), Phrase(body=UNRELATED))
+ table = admit_seeds(cache, seeds, baseline, EVERY_LAYER)
+ assert table.phrases == seeds
+
+ def test_a_shape_offered_twice_is_held_once(self, cache: MatchCache, baseline: Tuple[Parse, ...]) -> None:
+ seed = Phrase(body=LEANED_ON)
+ table = admit_seeds(cache, (seed, seed), baseline, EVERY_LAYER)
+ assert table.phrases == (seed,)
+
+
+class TestSeedsBeyondTheIdsATokenReaches:
+ """Where the instruments offer more shapes than a token can name, payment decides."""
+
+ def test_the_table_holds_the_ids_a_token_reaches(
+ self,
+ cache: MatchCache,
+ baseline: Tuple[Parse, ...],
+ ) -> None:
+ table = admit_seeds(cache, crowded_seeds(), baseline, EVERY_LAYER)
+ assert len(table) == MAX_PHRASE_IDS
+
+ def test_the_shape_the_plane_leans_on_is_kept(
+ self,
+ cache: MatchCache,
+ baseline: Tuple[Parse, ...],
+ ) -> None:
+ table = admit_seeds(cache, crowded_seeds(), baseline, EVERY_LAYER)
+ assert Phrase(body=LEANED_ON) in table.phrases
+
+ def test_the_shapes_the_plane_plays_outrank_the_ones_it_never_plays(
+ self,
+ cache: MatchCache,
+ baseline: Tuple[Parse, ...],
+ ) -> None:
+ """The plane leans on one shape and reaches for another seldom, in that order."""
+ table = admit_seeds(cache, crowded_seeds(), baseline, EVERY_LAYER)
+ assert [table[0].body, table[1].body] == [LEANED_ON, PLAYED_SELDOM]
+
+ def test_the_run_says_the_table_could_not_take_them_all(
+ self,
+ cache: MatchCache,
+ baseline: Tuple[Parse, ...],
+ caplog: pytest.LogCaptureFixture,
+ ) -> None:
+ with caplog.at_level(logging.WARNING):
+ admit_seeds(cache, crowded_seeds(), baseline, EVERY_LAYER)
+
+ assert str(MAX_PHRASE_IDS) in caplog.text
+
+
+def crowded_seeds() -> Tuple[Phrase, ...]:
+ """More shapes than a token can name, two of which the plane actually plays."""
+ return (*unplayed_seeds(), Phrase(body=LEANED_ON), Phrase(body=PLAYED_SELDOM))
diff --git a/tests/unit/sampletones_player/compression/test_decode.py b/tests/unit/sampletones_player/compression/test_decode.py
new file mode 100644
index 000000000..5ecb01256
--- /dev/null
+++ b/tests/unit/sampletones_player/compression/test_decode.py
@@ -0,0 +1,53 @@
+from typing import Final
+
+from sampletones_player.compression.decode import decode_plane
+from sampletones_player.compression.dictionary.phrase import Phrase
+from sampletones_player.compression.dictionary.table import phrase_table
+from sampletones_player.specification.compression import PHRASE_ID_ESCAPE, TokenTag
+
+MOTIF: Final[bytes] = bytes((40, 44, 47))
+DICTIONARY = phrase_table((Phrase(body=MOTIF),))
+
+
+class TestWhatTheDriverReadsFromAStream:
+ """The decoder is the reading the 6502 performs, stated where it is testable."""
+
+ def test_a_literal_writes_the_values_that_follow_it(self) -> None:
+ stream = bytes((TokenTag.LITERAL | 2, 5, 6, 7))
+ assert decode_plane(stream, DICTIONARY, 3) == bytes((5, 6, 7))
+
+ def test_a_hold_writes_the_value_the_plane_reached(self) -> None:
+ stream = bytes((TokenTag.LITERAL | 0, 9, TokenTag.HOLD | 2))
+ assert decode_plane(stream, DICTIONARY, 4) == bytes((9, 9, 9, 9))
+
+ def test_a_phrase_writes_the_body_the_table_holds(self) -> None:
+ stream = bytes((TokenTag.PHRASE | 0, len(MOTIF) - 1))
+ assert decode_plane(stream, DICTIONARY, len(MOTIF)) == MOTIF
+
+ def test_a_phrase_running_past_its_body_holds_the_value_it_ended_on(self) -> None:
+ stream = bytes((TokenTag.PHRASE | 0, len(MOTIF) + 1))
+ assert decode_plane(stream, DICTIONARY, len(MOTIF) + 2) == MOTIF + bytes((MOTIF[-1],)) * 2
+
+ def test_a_phrase_cut_short_writes_as_much_of_the_body_as_sounded(self) -> None:
+ stream = bytes((TokenTag.PHRASE | 0, 1))
+ assert decode_plane(stream, DICTIONARY, 2) == MOTIF[:2]
+
+ def test_a_shifted_phrase_writes_the_body_moved_by_the_shift(self) -> None:
+ stream = bytes((TokenTag.TRANSPOSED_PHRASE | 0, len(MOTIF) - 1, 5))
+ assert decode_plane(stream, DICTIONARY, len(MOTIF)) == bytes(value + 5 for value in MOTIF)
+
+ def test_a_shift_walks_the_byte_around_where_it_reaches_past_one(self) -> None:
+ """The driver adds the shift to a byte, so a fall reaches it as the byte that wraps to it."""
+ stream = bytes((TokenTag.TRANSPOSED_PHRASE | 0, 0, 0xFD))
+ assert decode_plane(stream, DICTIONARY, 1) == bytes((MOTIF[0] - 3,))
+
+ def test_an_escaped_phrase_names_its_id_in_the_byte_that_follows(self) -> None:
+ table = phrase_table(
+ tuple(Phrase(body=bytes((value, value))) for value in range(PHRASE_ID_ESCAPE)) + (Phrase(body=MOTIF),)
+ )
+ stream = bytes((TokenTag.PHRASE | PHRASE_ID_ESCAPE, PHRASE_ID_ESCAPE, len(MOTIF) - 1))
+ assert decode_plane(stream, table, len(MOTIF)) == MOTIF
+
+ def test_a_token_reaching_past_the_song_stops_where_the_song_does(self) -> None:
+ stream = bytes((TokenTag.LITERAL | 0, 4, TokenTag.HOLD | 63))
+ assert decode_plane(stream, DICTIONARY, 3) == bytes((4, 4, 4))
diff --git a/tests/unit/sampletones_player/compression/test_encode.py b/tests/unit/sampletones_player/compression/test_encode.py
new file mode 100644
index 000000000..9c01f6416
--- /dev/null
+++ b/tests/unit/sampletones_player/compression/test_encode.py
@@ -0,0 +1,201 @@
+from typing import Final, FrozenSet, Tuple
+
+import pytest
+
+from sampletones_player.compression.decode import decode_planes
+from sampletones_player.compression.dictionary.phrase import Phrase
+from sampletones_player.compression.encode import emit, encode_planes
+from sampletones_player.compression.options import CodecOptions
+from sampletones_player.compression.planes.channel import ChannelPlanes, TonePlanes
+from sampletones_player.compression.planes.song import SongPlanes
+from sampletones_player.compression.progress.report import CodecProgress
+from sampletones_player.compression.tokens.hold import HoldToken
+from sampletones_player.compression.tokens.literal import LiteralToken
+from sampletones_player.compression.tokens.phrase import PhraseToken
+from sampletones_player.specification.compression import (
+ MAX_PHRASE_TICKS,
+ PHRASE_ID_ESCAPE,
+ TokenTag,
+)
+from sampletones_shared.exceptions import OperationCanceled
+from tests.suite.progress import FIRST_REPORT, RecordingReporter
+
+EVERY_LAYER: Final[CodecOptions] = CodecOptions(
+ holds=True,
+ phrases=True,
+ transposition=True,
+ search=True,
+)
+SEEDED: Final[CodecOptions] = CodecOptions(
+ holds=True,
+ phrases=True,
+ transposition=True,
+ search=False,
+)
+NO_BOUNDARIES: Final[FrozenSet[int]] = frozenset()
+MOTIF: Final[bytes] = bytes((40, 44, 47, 44))
+TIMBRE: Final[bytes] = bytes((0x3F, 0x3A, 0x35, 0x30))
+REPEATS: Final[int] = 12
+
+
+def song_planes(control: bytes, value: bytes) -> SongPlanes:
+ unbent = bytes(len(control))
+ channel = TonePlanes(control=control, value=value, bend=unbent)
+ resting = TonePlanes(control=unbent, value=bytes(len(value)), bend=unbent)
+ silent = ChannelPlanes(control=unbent, value=bytes(len(value)))
+ return SongPlanes(pulse1=channel, pulse2=resting, triangle=resting, noise=silent)
+
+
+class TestWhatATokenLooksLikeOnTheBus:
+ """The opcode layout is what the driver reads, so the bytes themselves are the contract."""
+
+ def test_a_hold_carries_its_count_inside_its_opcode(self) -> None:
+ assert emit((HoldToken(ticks=3),)) == bytes((TokenTag.HOLD | 2,))
+
+ def test_a_literal_carries_its_length_inside_its_opcode(self) -> None:
+ assert emit((LiteralToken(values=bytes((0x10, 0x20))),)) == bytes((TokenTag.LITERAL | 1, 0x10, 0x20))
+
+ def test_a_phrase_carries_a_cheap_id_inside_its_opcode(self) -> None:
+ token = PhraseToken(phrase_id=2, ticks=5, transpose=0)
+ assert emit((token,)) == bytes((TokenTag.PHRASE | 2, 4))
+
+ def test_a_shifted_phrase_states_the_shift_after_the_count(self) -> None:
+ token = PhraseToken(phrase_id=2, ticks=5, transpose=0xFD)
+ assert emit((token,)) == bytes((TokenTag.TRANSPOSED_PHRASE | 2, 4, 0xFD))
+
+ def test_a_phrase_beyond_the_cheap_ids_names_itself_in_the_byte_that_follows(self) -> None:
+ token = PhraseToken(phrase_id=200, ticks=MAX_PHRASE_TICKS, transpose=0)
+ assert emit((token,)) == bytes((TokenTag.PHRASE | PHRASE_ID_ESCAPE, 200, MAX_PHRASE_TICKS - 1))
+
+ def test_a_stream_takes_the_bytes_the_parse_counted(self) -> None:
+ tokens = (LiteralToken(values=MOTIF), HoldToken(ticks=4), PhraseToken(phrase_id=1, ticks=2, transpose=3))
+ assert len(emit(tokens)) == sum(token.size for token in tokens)
+
+
+class TestEncodingASong:
+ """The instruments seed the dictionary, the search fills the rest, and the table settles."""
+
+ def test_a_song_plays_back_as_the_planes_it_was_written_from(self) -> None:
+ planes = song_planes(TIMBRE * REPEATS, MOTIF * REPEATS)
+ compressed = encode_planes(
+ planes,
+ (),
+ options=EVERY_LAYER,
+ boundaries=NO_BOUNDARIES,
+ )
+ assert decode_planes(compressed) == planes
+
+ def test_the_figure_the_song_repeats_reaches_the_dictionary(self) -> None:
+ """The search states the figure at whatever length pays best, the motif being its unit."""
+ planes = song_planes(bytes((0x30,)) * (len(MOTIF) * REPEATS), MOTIF * REPEATS)
+ compressed = encode_planes(
+ planes,
+ (),
+ options=EVERY_LAYER,
+ boundaries=NO_BOUNDARIES,
+ )
+ assert compressed.phrases.phrases
+ for phrase in compressed.phrases.phrases:
+ assert phrase.body == MOTIF * (len(phrase.body) // len(MOTIF))
+
+ def test_a_seed_the_song_never_leans_on_leaves_the_dictionary(self) -> None:
+ """A phrase earns its entry by sparing more than the entry costs."""
+ planes = song_planes(bytes((0x30,)) * len(MOTIF), MOTIF)
+ compressed = encode_planes(
+ planes,
+ (Phrase(body=MOTIF),),
+ options=SEEDED,
+ boundaries=NO_BOUNDARIES,
+ )
+ assert compressed.phrases.phrases == ()
+
+ def test_the_figure_played_most_takes_the_cheapest_id(self) -> None:
+ planes = song_planes(bytes((0x30,)) * (len(MOTIF) * REPEATS), MOTIF * REPEATS)
+ seeds: Tuple[Phrase, ...] = (Phrase(body=bytes((0x30,)) * 8), Phrase(body=MOTIF))
+ compressed = encode_planes(
+ planes,
+ seeds,
+ options=SEEDED,
+ boundaries=NO_BOUNDARIES,
+ )
+ assert compressed.phrases[0] == Phrase(body=MOTIF)
+
+ def test_a_song_re_entered_at_a_boundary_still_plays_back_whole(self) -> None:
+ planes = song_planes(TIMBRE * REPEATS, MOTIF * REPEATS)
+ compressed = encode_planes(
+ planes,
+ (),
+ options=EVERY_LAYER,
+ boundaries=frozenset({len(MOTIF) * 3}),
+ )
+ assert decode_planes(compressed) == planes
+
+ def test_every_plane_of_the_song_carries_a_stream(self) -> None:
+ planes = song_planes(bytes((0x30,)) * 4, MOTIF)
+ compressed = encode_planes(
+ planes,
+ (),
+ options=EVERY_LAYER,
+ boundaries=NO_BOUNDARIES,
+ )
+ assert len(compressed.streams) == len(planes.planes)
+ assert compressed.ticks == planes.ticks
+
+
+class TestWhatAnEncodingSaysAboutItself:
+ """Compressing a song takes as long as the song decides, so it reports as it runs."""
+
+ def test_the_last_word_is_what_the_run_answered_with(self) -> None:
+ reporter: RecordingReporter[CodecProgress] = RecordingReporter()
+ planes = song_planes(TIMBRE * REPEATS, MOTIF * REPEATS)
+ compressed = encode_planes(
+ planes,
+ (),
+ options=EVERY_LAYER,
+ boundaries=NO_BOUNDARIES,
+ report=reporter,
+ )
+ assert reporter.last == CodecProgress(phrases=len(compressed.phrases), size=compressed.size)
+
+ def test_a_run_looks_up_often_enough_to_be_stopped(self) -> None:
+ """The stretch between two reports is one plane, so a withdrawal lands within one."""
+ reporter: RecordingReporter[CodecProgress] = RecordingReporter()
+ planes = song_planes(TIMBRE * REPEATS, MOTIF * REPEATS)
+ encode_planes(
+ planes,
+ (),
+ options=EVERY_LAYER,
+ boundaries=NO_BOUNDARIES,
+ report=reporter,
+ )
+ assert len(reporter.reports) > len(planes.planes)
+
+
+class TestWithdrawingAnEncoding:
+ """A caller that stops wanting the song stops the work producing it."""
+
+ def test_a_withdrawn_run_unwinds(self) -> None:
+ reporter: RecordingReporter[CodecProgress] = RecordingReporter(withdraw_at=FIRST_REPORT)
+ planes = song_planes(TIMBRE * REPEATS, MOTIF * REPEATS)
+ with pytest.raises(OperationCanceled):
+ encode_planes(
+ planes,
+ (),
+ options=EVERY_LAYER,
+ boundaries=NO_BOUNDARIES,
+ report=reporter,
+ )
+
+ def test_a_withdrawn_run_stops_where_it_was_told(self) -> None:
+ reporter: RecordingReporter[CodecProgress] = RecordingReporter(withdraw_at=FIRST_REPORT)
+ planes = song_planes(TIMBRE * REPEATS, MOTIF * REPEATS)
+ with pytest.raises(OperationCanceled):
+ encode_planes(
+ planes,
+ (),
+ options=EVERY_LAYER,
+ boundaries=NO_BOUNDARIES,
+ report=reporter,
+ )
+
+ assert len(reporter.reports) == FIRST_REPORT
diff --git a/tests/unit/sampletones_player/compression/test_entries.py b/tests/unit/sampletones_player/compression/test_entries.py
new file mode 100644
index 000000000..282916a6b
--- /dev/null
+++ b/tests/unit/sampletones_player/compression/test_entries.py
@@ -0,0 +1,42 @@
+from typing import Final
+
+import pytest
+
+from sampletones_player.compression.encode import emit
+from sampletones_player.compression.entries import stream_entry
+from sampletones_player.compression.tokens.hold import HoldToken
+from sampletones_player.compression.tokens.literal import LiteralToken
+from sampletones_player.compression.tokens.phrase import PhraseToken
+
+FIRST_TICK: Final[int] = 0
+PHRASE_ID: Final[int] = 2
+SHIFT: Final[int] = 7
+
+
+class TestWhereAStreamIsReEntered:
+ """A song coming round resumes at a byte, and the walk over the tokens finds it."""
+
+ def test_the_first_tick_is_the_streams_own_first_byte(self) -> None:
+ stream = emit([LiteralToken(values=b"\x01\x02"), HoldToken(ticks=3)])
+ assert stream_entry(stream, FIRST_TICK) == 0
+
+ def test_a_tick_a_token_starts_answers_with_that_tokens_byte(self) -> None:
+ literal = LiteralToken(values=b"\x01\x02")
+ stream = emit([literal, HoldToken(ticks=3), PhraseToken(phrase_id=PHRASE_ID, ticks=4, transpose=0)])
+ assert stream_entry(stream, literal.ticks) == literal.size
+
+ def test_a_tick_further_in_walks_past_every_token_before_it(self) -> None:
+ tokens = [
+ LiteralToken(values=b"\x01\x02"),
+ HoldToken(ticks=3),
+ PhraseToken(phrase_id=PHRASE_ID, ticks=4, transpose=SHIFT),
+ ]
+ stream = emit(tokens)
+ covered = sum(token.ticks for token in tokens[:2])
+ assert stream_entry(stream, covered) == sum(token.size for token in tokens[:2])
+
+ def test_a_tick_a_token_spans_is_refused(self) -> None:
+ """A stream re-entered mid-token would leave the driver reading operands as opcodes."""
+ stream = emit([HoldToken(ticks=8)])
+ with pytest.raises(ValueError, match="spans tick"):
+ stream_entry(stream, 3)
diff --git a/tests/unit/sampletones_player/compression/test_pitch.py b/tests/unit/sampletones_player/compression/test_pitch.py
new file mode 100644
index 000000000..19a867aef
--- /dev/null
+++ b/tests/unit/sampletones_player/compression/test_pitch.py
@@ -0,0 +1,44 @@
+from typing import Final
+
+from sampletones_player.compression.pitch import PITCH_COUNT, PitchTable
+from sampletones_player.specification.registers import (
+ MAX_REGISTER_VALUE,
+ TIMER_HIGH_SHIFT,
+)
+from sampletones_shared.constants.music import LIMIT_MAX_PITCH, LIMIT_MIN_PITCH
+from sampletones_shared.music import Tuning
+
+TUNING: Final[Tuning] = Tuning()
+
+
+class TestThePitchTableNamesEveryPitchAProjectSounds:
+ """A plane names a pitch by its distance above the lowest one the project reaches."""
+
+ def test_the_table_spans_the_pitches_the_project_offers(self) -> None:
+ table = PitchTable.from_tuning(TUNING)
+ assert len(table.timers) == PITCH_COUNT == LIMIT_MAX_PITCH - LIMIT_MIN_PITCH + 1
+
+ def test_every_timer_resolves_back_to_an_index_sounding_it(self) -> None:
+ """Pitches clamped to the same divider sound alike, so one index stands for them."""
+ table = PitchTable.from_tuning(TUNING)
+ indices = table.indices
+ for timer in table.timers:
+ assert table.timers[indices[timer]] == timer
+
+ def test_a_higher_index_never_sounds_a_slower_divider(self) -> None:
+ table = PitchTable.from_tuning(TUNING)
+ for timer, following in zip(table.timers, table.timers[1:]):
+ assert following <= timer
+
+ def test_the_driver_reads_the_low_bytes_then_the_high_bytes(self) -> None:
+ table = PitchTable.from_tuning(TUNING)
+ data = table.data
+ assert len(data) == 2 * PITCH_COUNT
+ for index, timer in enumerate(table.timers):
+ assert data[index] == timer & MAX_REGISTER_VALUE
+ assert data[PITCH_COUNT + index] == timer >> TIMER_HIGH_SHIFT
+
+ def test_a_retuned_table_moves_the_dividers(self) -> None:
+ standard = PitchTable.from_tuning(TUNING)
+ retuned = PitchTable.from_tuning(Tuning(a4_frequency=432.0))
+ assert retuned.timers != standard.timers
diff --git a/tests/unit/sampletones_player/compression/test_seeds.py b/tests/unit/sampletones_player/compression/test_seeds.py
new file mode 100644
index 000000000..75ed651a2
--- /dev/null
+++ b/tests/unit/sampletones_player/compression/test_seeds.py
@@ -0,0 +1,68 @@
+from typing import Final, Tuple
+
+import pytest
+
+from sampletones_core.constants.enums import ChannelName
+from sampletones_core.project.project import Project
+from sampletones_core.timers.utils import get_timer_table
+from sampletones_player.compression.pitch import PitchTable
+from sampletones_player.compression.planes.channel import ChannelPlanes
+from sampletones_player.compression.planes.separate import channel_planes
+from sampletones_player.compression.seeds import phrases_from_project
+from sampletones_player.registers.channel import channel_registers
+from sampletones_shared.music import Tuning
+from tests.suite.performance import make_pulse_reconstruction, project_with_sample
+
+TUNING: Final[Tuning] = Tuning()
+ROWS_PER_PATTERN: Final[int] = 8
+SOUNDING_TICKS: Final[int] = 5
+
+
+@pytest.fixture
+def project() -> Project:
+ """A project playing one pulse slice at a row."""
+ reconstruction = make_pulse_reconstruction(count=SOUNDING_TICKS)
+ built, _ = project_with_sample(reconstruction, rows_per_pattern=ROWS_PER_PATTERN)
+ return built
+
+
+@pytest.fixture
+def slice_planes(project: Project) -> ChannelPlanes:
+ """The planes the project's own slice writes on the channel it plays."""
+ instructions = project.voices[0].reconstruction.get_channel_instructions(ChannelName.PULSE1)
+ registers = channel_registers(
+ ChannelName.PULSE1,
+ {ChannelName.PULSE1: instructions},
+ get_timer_table(TUNING),
+ )
+ return channel_planes(ChannelName.PULSE1, registers, PitchTable.from_tuning(TUNING))
+
+
+def _offered(project: Project) -> Tuple[bytes, ...]:
+ return tuple(phrase.body for phrase in phrases_from_project(project, TUNING))
+
+
+class TestTheInstrumentsSeedTheDictionary:
+ """A song plays sample slices at rows, so the shapes its planes repeat are the slices."""
+
+ def test_the_phrases_are_the_planes_the_slice_turns_over(
+ self,
+ project: Project,
+ slice_planes: ChannelPlanes,
+ ) -> None:
+ turning = tuple(plane for plane in slice_planes.ordered if len(set(plane)) > 1)
+ assert _offered(project) == turning
+
+ def test_a_plane_holding_one_value_offers_the_dictionary_nothing(
+ self,
+ project: Project,
+ slice_planes: ChannelPlanes,
+ ) -> None:
+ """A hold covers such a plane more cheaply than any phrase naming it could."""
+ held = tuple(plane for plane in slice_planes.ordered if len(set(plane)) == 1)
+ assert held
+ assert not set(held) & set(_offered(project))
+
+ def test_a_project_holding_no_sample_offers_nothing(self, project: Project) -> None:
+ project.voices.clear()
+ assert phrases_from_project(project, TUNING) == ()
diff --git a/tests/unit/sampletones_player/compression/test_song.py b/tests/unit/sampletones_player/compression/test_song.py
new file mode 100644
index 000000000..133055fda
--- /dev/null
+++ b/tests/unit/sampletones_player/compression/test_song.py
@@ -0,0 +1,96 @@
+from typing import Final, Optional, Tuple
+
+import pytest
+
+from sampletones_player.compression.dictionary.phrase import Phrase
+from sampletones_player.compression.song import compress_song, decompress_song
+from sampletones_player.registers.streams import ChannelStreams
+from tests.suite.player import (
+ PLAYER_FULL_VOLUME,
+ PLAYER_OCTAVE_UP_TIMER,
+ PLAYER_PITCHES,
+ PLAYER_REFERENCE_TIMER,
+ PLAYER_SILENT_VOLUME,
+ pulse_tick,
+ resting_streams,
+)
+
+SOUNDING: Final = pulse_tick(PLAYER_FULL_VOLUME, 0, PLAYER_REFERENCE_TIMER)
+RESTING: Final = pulse_tick(PLAYER_SILENT_VOLUME, 0, PLAYER_REFERENCE_TIMER)
+OCTAVE_UP: Final = pulse_tick(PLAYER_FULL_VOLUME, 0, PLAYER_OCTAVE_UP_TIMER)
+
+HELD_TICKS: Final[int] = 12
+MIDDLE_TICK: Final[int] = 6
+UNSOUNDED_TIMER: Final[int] = 0x154
+NO_SEEDS: Final[Tuple[Phrase, ...]] = ()
+
+
+def played(
+ streams: ChannelStreams,
+ loop_tick: Optional[int] = None,
+) -> ChannelStreams:
+ return decompress_song(
+ compress_song(
+ streams,
+ PLAYER_PITCHES,
+ seeds=NO_SEEDS,
+ loop_tick=loop_tick,
+ ),
+ PLAYER_PITCHES,
+ )
+
+
+class TestTheSongPlaysBackTheRegistersItWasCompressedFrom:
+ """What the codec is held to at the song level: every channel writes what it was given."""
+
+ def test_every_tick_writes_the_registers_it_was_given(self) -> None:
+ streams = resting_streams((SOUNDING, OCTAVE_UP, RESTING))
+ rebuilt = played(streams)
+ assert [rebuilt.at(tick) for tick in range(streams.ticks)] == [
+ streams.at(tick) for tick in range(streams.ticks)
+ ]
+
+ def test_a_channel_running_out_early_is_carried_to_the_songs_length(self) -> None:
+ streams = resting_streams((SOUNDING, OCTAVE_UP, RESTING))
+ rebuilt = played(streams)
+ assert len(rebuilt.noise) == streams.ticks
+ assert set(rebuilt.noise) == {streams.noise[0]}
+
+
+class TestASongThatRepeatsReEntersItsStreams:
+ """A loop returns partway through, so the tick it returns to starts a token of its own."""
+
+ def test_the_tick_a_song_returns_to_costs_the_streams_a_token(self) -> None:
+ """Splitting a run at the loop tick is what leaves the driver a token to resume on."""
+ streams = resting_streams((SOUNDING,) * HELD_TICKS)
+ looped = compress_song(
+ streams,
+ PLAYER_PITCHES,
+ seeds=NO_SEEDS,
+ loop_tick=MIDDLE_TICK,
+ )
+ played_once = compress_song(
+ streams,
+ PLAYER_PITCHES,
+ seeds=NO_SEEDS,
+ loop_tick=None,
+ )
+ assert looped.size > played_once.size
+
+ def test_the_song_writes_the_same_registers_either_way(self) -> None:
+ streams = resting_streams((SOUNDING,) * HELD_TICKS)
+ assert played(streams, MIDDLE_TICK) == played(streams)
+
+
+class TestAPlaneNamesPitchesTheTableSounds:
+ """A tone channel reaches a plane as pitch indices, so its timers are the table's own."""
+
+ def test_a_timer_no_pitch_sounds_is_refused(self) -> None:
+ streams = resting_streams((pulse_tick(PLAYER_FULL_VOLUME, 0, UNSOUNDED_TIMER),))
+ with pytest.raises(ValueError, match=str(UNSOUNDED_TIMER)):
+ compress_song(
+ streams,
+ PLAYER_PITCHES,
+ seeds=NO_SEEDS,
+ loop_tick=None,
+ )
diff --git a/tests/unit/sampletones_player/compression/tokens/__init__.py b/tests/unit/sampletones_player/compression/tokens/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/tests/unit/sampletones_player/compression/tokens/test_hold.py b/tests/unit/sampletones_player/compression/tokens/test_hold.py
new file mode 100644
index 000000000..4e9a86220
--- /dev/null
+++ b/tests/unit/sampletones_player/compression/tokens/test_hold.py
@@ -0,0 +1,9 @@
+from sampletones_player.compression.tokens.hold import HoldToken
+from sampletones_player.specification.compression import MAX_HOLD_TICKS, OPCODE_SIZE
+
+
+class TestWhatAHoldCosts:
+ """A hold states a count inside its own opcode, so its length is free."""
+
+ def test_a_hold_costs_its_opcode_however_long_it_runs(self) -> None:
+ assert HoldToken(ticks=1).size == HoldToken(ticks=MAX_HOLD_TICKS).size == OPCODE_SIZE
diff --git a/tests/unit/sampletones_player/compression/tokens/test_literal.py b/tests/unit/sampletones_player/compression/tokens/test_literal.py
new file mode 100644
index 000000000..d78b237bf
--- /dev/null
+++ b/tests/unit/sampletones_player/compression/tokens/test_literal.py
@@ -0,0 +1,16 @@
+from sampletones_player.compression.tokens.literal import LiteralToken
+from sampletones_player.compression.tokens.sizes import literal_size
+from sampletones_player.specification.compression import OPCODE_SIZE
+
+SPELLED_OUT: int = 5
+
+
+class TestWhatALiteralCosts:
+ """A literal spells its values out, so it costs its opcode and every one of them."""
+
+ def test_a_literal_covers_a_tick_for_every_value_it_states(self) -> None:
+ assert LiteralToken(values=bytes(SPELLED_OUT)).ticks == SPELLED_OUT
+
+ def test_a_literal_costs_its_opcode_and_the_values_it_spells_out(self) -> None:
+ token = LiteralToken(values=bytes(SPELLED_OUT))
+ assert token.size == literal_size(SPELLED_OUT) == OPCODE_SIZE + SPELLED_OUT
diff --git a/tests/unit/sampletones_player/compression/tokens/test_phrase.py b/tests/unit/sampletones_player/compression/tokens/test_phrase.py
new file mode 100644
index 000000000..b60c280d8
--- /dev/null
+++ b/tests/unit/sampletones_player/compression/tokens/test_phrase.py
@@ -0,0 +1,55 @@
+from dataclasses import dataclass
+
+import pytest
+
+from sampletones_player.compression.tokens.phrase import PhraseToken
+from sampletones_player.compression.tokens.sizes import phrase_size
+from sampletones_player.specification.binary import MAX_BYTE_VALUE
+from sampletones_player.specification.compression import (
+ CHEAP_PHRASE_IDS,
+ OPCODE_SIZE,
+ PHRASE_COUNT_SIZE,
+ PHRASE_ESCAPE_SIZE,
+ TRANSPOSE_SIZE,
+)
+from tests.suite.base import BaseTestSuite
+from tests.suite.case import BaseAutolabelTestCase
+
+
+class TestWhatAPhraseTokenCosts(BaseTestSuite):
+ """A token's bytes are what the parse is decided in, so each states its own size."""
+
+ @dataclass(frozen=True, kw_only=True)
+ class TestCase(BaseAutolabelTestCase):
+ expected: int
+ phrase_id: int
+ transpose: int
+
+ @property
+ def label(self) -> str:
+ shift = "shifted" if self.transpose else "plain"
+ return f"phrase_{self.phrase_id}_{shift}"
+
+ test_cases = (
+ TestCase(phrase_id=0, transpose=0, expected=OPCODE_SIZE + PHRASE_COUNT_SIZE),
+ TestCase(
+ phrase_id=0,
+ transpose=1,
+ expected=OPCODE_SIZE + PHRASE_COUNT_SIZE + TRANSPOSE_SIZE,
+ ),
+ TestCase(
+ phrase_id=CHEAP_PHRASE_IDS,
+ transpose=0,
+ expected=OPCODE_SIZE + PHRASE_COUNT_SIZE + PHRASE_ESCAPE_SIZE,
+ ),
+ TestCase(
+ phrase_id=CHEAP_PHRASE_IDS,
+ transpose=MAX_BYTE_VALUE,
+ expected=OPCODE_SIZE + PHRASE_COUNT_SIZE + PHRASE_ESCAPE_SIZE + TRANSPOSE_SIZE,
+ ),
+ )
+
+ @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label)
+ def test_a_phrase_token_pays_for_the_id_and_the_shift_it_names(self, test_case: TestCase) -> None:
+ token = PhraseToken(phrase_id=test_case.phrase_id, ticks=1, transpose=test_case.transpose)
+ assert token.size == test_case.expected == phrase_size(test_case.phrase_id, test_case.transpose)
diff --git a/tests/unit/sampletones_player/compression/tokens/test_span.py b/tests/unit/sampletones_player/compression/tokens/test_span.py
new file mode 100644
index 000000000..6420af0ef
--- /dev/null
+++ b/tests/unit/sampletones_player/compression/tokens/test_span.py
@@ -0,0 +1,128 @@
+from dataclasses import dataclass
+from typing import Final
+
+import pytest
+
+from sampletones_player.compression.encode import emit
+from sampletones_player.compression.tokens.hold import HoldToken
+from sampletones_player.compression.tokens.literal import LiteralToken
+from sampletones_player.compression.tokens.phrase import PhraseToken
+from sampletones_player.compression.tokens.span import TokenSpan, token_span
+from sampletones_player.compression.tokens.types import TokenUnion
+from sampletones_player.specification.compression import (
+ MAX_HOLD_TICKS,
+ MAX_PHRASE_TICKS,
+ PHRASE_ID_ESCAPE,
+)
+from tests.suite.base import BaseTestSuite
+from tests.suite.case import BaseAutolabelTestCase
+
+FIRST_TOKEN: Final[int] = 0
+CHEAP_ID: Final[int] = 1
+SHIFT: Final[int] = 5
+PHRASE_PLAY_TICKS: Final[int] = 10
+
+
+class TestWhatAWrittenTokenTakesAndCovers(BaseTestSuite):
+ """A stream is walked by its opcodes alone, so each token states its own bytes and ticks."""
+
+ @dataclass(frozen=True, kw_only=True)
+ class TestCase(BaseAutolabelTestCase):
+ expected: TokenSpan
+ name: str
+ token: TokenUnion
+
+ @property
+ def label(self) -> str:
+ return self.name
+
+ test_cases = (
+ TestCase(name="hold", token=HoldToken(ticks=7), expected=TokenSpan(size=1, ticks=7)),
+ TestCase(
+ name="hold-longest",
+ token=HoldToken(ticks=MAX_HOLD_TICKS),
+ expected=TokenSpan(size=1, ticks=MAX_HOLD_TICKS),
+ ),
+ TestCase(
+ name="literal",
+ token=LiteralToken(values=b"\x01\x02\x03"),
+ expected=TokenSpan(size=4, ticks=3),
+ ),
+ TestCase(
+ name="phrase",
+ token=PhraseToken(phrase_id=CHEAP_ID, ticks=PHRASE_PLAY_TICKS, transpose=0),
+ expected=TokenSpan(size=2, ticks=PHRASE_PLAY_TICKS),
+ ),
+ TestCase(
+ name="phrase-shifted",
+ token=PhraseToken(phrase_id=CHEAP_ID, ticks=PHRASE_PLAY_TICKS, transpose=SHIFT),
+ expected=TokenSpan(size=3, ticks=PHRASE_PLAY_TICKS),
+ ),
+ TestCase(
+ name="phrase-escaped",
+ token=PhraseToken(phrase_id=PHRASE_ID_ESCAPE, ticks=PHRASE_PLAY_TICKS, transpose=0),
+ expected=TokenSpan(size=3, ticks=PHRASE_PLAY_TICKS),
+ ),
+ TestCase(
+ name="phrase-escaped-shifted",
+ token=PhraseToken(
+ phrase_id=PHRASE_ID_ESCAPE,
+ ticks=PHRASE_PLAY_TICKS,
+ transpose=SHIFT,
+ ),
+ expected=TokenSpan(size=4, ticks=PHRASE_PLAY_TICKS),
+ ),
+ TestCase(
+ name="phrase-longest",
+ token=PhraseToken(phrase_id=CHEAP_ID, ticks=MAX_PHRASE_TICKS, transpose=0),
+ expected=TokenSpan(size=2, ticks=MAX_PHRASE_TICKS),
+ ),
+ )
+
+ @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label)
+ def test_the_span_states_what_the_token_takes(self, test_case: TestCase) -> None:
+ span = token_span(emit([test_case.token]), FIRST_TOKEN)
+ assert span.size == test_case.expected.size
+
+ @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label)
+ def test_the_span_states_what_the_token_covers(self, test_case: TestCase) -> None:
+ span = token_span(emit([test_case.token]), FIRST_TOKEN)
+ assert span.ticks == test_case.expected.ticks
+
+ @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label)
+ def test_the_span_reaches_the_byte_the_next_token_begins_at(self, test_case: TestCase) -> None:
+ stream = emit([test_case.token, HoldToken(ticks=1)])
+ assert token_span(stream, FIRST_TOKEN).size == len(stream) - 1
+
+
+class TestWalkingAStream:
+ """The spans of a stream's tokens sum to the stream itself."""
+
+ def test_the_sizes_reach_the_streams_last_byte(self) -> None:
+ tokens = (
+ LiteralToken(values=b"\x01\x02"),
+ HoldToken(ticks=3),
+ PhraseToken(phrase_id=CHEAP_ID, ticks=4, transpose=SHIFT),
+ )
+ stream = emit(tokens)
+ position = 0
+ for _ in tokens:
+ position += token_span(stream, position).size
+
+ assert position == len(stream)
+
+ def test_the_ticks_reach_the_songs_length(self) -> None:
+ tokens = (
+ LiteralToken(values=b"\x01\x02"),
+ HoldToken(ticks=3),
+ PhraseToken(phrase_id=CHEAP_ID, ticks=4, transpose=0),
+ )
+ stream = emit(tokens)
+ position = 0
+ covered = 0
+ for _ in tokens:
+ span = token_span(stream, position)
+ covered += span.ticks
+ position += span.size
+
+ assert covered == 2 + 3 + 4
diff --git a/tests/unit/sampletones_player/driver/__init__.py b/tests/unit/sampletones_player/driver/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/tests/unit/sampletones_player/driver/assembler/__init__.py b/tests/unit/sampletones_player/driver/assembler/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/tests/unit/sampletones_player/driver/assembler/test_builder.py b/tests/unit/sampletones_player/driver/assembler/test_builder.py
new file mode 100644
index 000000000..0c8479146
--- /dev/null
+++ b/tests/unit/sampletones_player/driver/assembler/test_builder.py
@@ -0,0 +1,68 @@
+import shutil
+from pathlib import Path
+from typing import Final
+
+import pytest
+
+from sampletones_player.driver.addresses import DriverAddresses
+from sampletones_player.driver.assembler.builder import build_driver, verify_addresses
+from sampletones_player.driver.assembler.toolchain import ASSEMBLER
+from sampletones_player.driver.image import DriverImage
+from sampletones_player.specification.driver import (
+ DRIVER_CODE_NAME,
+ JUMP_ABSOLUTE_OPCODE,
+ JUMP_INSTRUCTION_SIZE,
+ LOAD_ADDRESS,
+)
+from sampletones_shared.exceptions import DriverBuildError
+from tests.suite.base import BaseTestSuite
+
+DISPLACEMENT: Final[int] = 0x0100
+RETURN_OPCODE: Final[int] = 0x60
+CODE: Final[bytes] = bytes((JUMP_ABSOLUTE_OPCODE, 0x00, 0x80, JUMP_ABSOLUTE_OPCODE, 0x00, 0x80, RETURN_OPCODE))
+
+cc65_installed = pytest.mark.skipif(shutil.which(ASSEMBLER) is None, reason="cc65 assembles the driver")
+
+
+@pytest.fixture(name="built_driver", scope="module")
+def fixture_built_driver(tmp_path_factory: pytest.TempPathFactory) -> DriverImage:
+ return build_driver(tmp_path_factory.mktemp("driver"))
+
+
+class TestTheLayoutABuildProduces(BaseTestSuite):
+ """A build's own layout, held to the addresses the exporter reads the driver through."""
+
+ @staticmethod
+ def displaced_image(displacement: int) -> DriverImage:
+ load = LOAD_ADDRESS + displacement
+ addresses = DriverAddresses(
+ load=load,
+ init=load,
+ play=load + JUMP_INSTRUCTION_SIZE,
+ song=load + len(CODE),
+ )
+ return DriverImage(code=CODE, addresses=addresses)
+
+ def test_a_displaced_image_names_the_load_address_that_moved(self) -> None:
+ with pytest.raises(DriverBuildError, match="load at"):
+ verify_addresses(self.displaced_image(DISPLACEMENT))
+
+ def test_a_displaced_image_names_the_song_address_that_moved(self) -> None:
+ with pytest.raises(DriverBuildError, match="song at"):
+ verify_addresses(self.displaced_image(DISPLACEMENT))
+
+
+@cc65_installed
+class TestTheDriverBuild(BaseTestSuite):
+ """The committed driver against the sources it is built from."""
+
+ def test_the_committed_driver_matches_its_sources(self, built_driver: DriverImage) -> None:
+ message = f"{DRIVER_CODE_NAME} is behind its sources: run `make player`"
+ assert built_driver.code == DriverImage.load().code, message
+
+ def test_the_linker_lays_the_driver_out_where_it_is_declared(self, built_driver: DriverImage) -> None:
+ assert built_driver.addresses == DriverImage.load().addresses
+
+ def test_the_build_writes_the_image_it_answers_with(self, tmp_path: Path) -> None:
+ built = build_driver(tmp_path)
+ assert (tmp_path / DRIVER_CODE_NAME).read_bytes() == built.code
diff --git a/tests/unit/sampletones_player/driver/assembler/test_labels.py b/tests/unit/sampletones_player/driver/assembler/test_labels.py
new file mode 100644
index 000000000..411d50765
--- /dev/null
+++ b/tests/unit/sampletones_player/driver/assembler/test_labels.py
@@ -0,0 +1,65 @@
+from pathlib import Path
+from typing import Final
+
+import pytest
+
+from sampletones_player.driver.assembler.labels import (
+ INIT_SYMBOL,
+ LOAD_SYMBOL,
+ PLAY_SYMBOL,
+ SONG_SYMBOL,
+ read_addresses,
+ read_labels,
+)
+from sampletones_player.specification.driver import INIT_ADDRESS, LOAD_ADDRESS, PLAY_ADDRESS
+from sampletones_shared.exceptions import DriverBuildError
+from tests.suite.base import BaseTestSuite
+
+LABELS_NAME: Final[str] = "driver.labels"
+CODE_LENGTH: Final[int] = 512
+SONG_ADDRESS: Final[int] = LOAD_ADDRESS + CODE_LENGTH
+LABEL_FILE: Final[str] = "\n".join(
+ (
+ f"al {LOAD_ADDRESS:06X} .{LOAD_SYMBOL}",
+ f"al {INIT_ADDRESS:06X} .{INIT_SYMBOL}",
+ f"al {PLAY_ADDRESS:06X} .{PLAY_SYMBOL}",
+ f"al {SONG_ADDRESS:06X} .{SONG_SYMBOL}",
+ "al 000002 .current_tick",
+ )
+)
+
+
+def write_labels(directory: Path, text: str) -> Path:
+ path = directory / LABELS_NAME
+ path.write_text(text)
+ return path
+
+
+class TestTheLabelsALinkerReports(BaseTestSuite):
+ """What a build reads back out of the label file its linker wrote."""
+
+ def test_every_symbol_carries_its_address(self, tmp_path: Path) -> None:
+ labels = read_labels(write_labels(tmp_path, LABEL_FILE))
+ assert labels[INIT_SYMBOL] == INIT_ADDRESS
+ assert labels[SONG_SYMBOL] == SONG_ADDRESS
+
+ def test_a_zero_page_symbol_is_read_alongside_the_program(self, tmp_path: Path) -> None:
+ assert read_labels(write_labels(tmp_path, LABEL_FILE))["current_tick"] == 2
+
+ def test_a_line_of_another_shape_carries_no_symbol(self, tmp_path: Path) -> None:
+ text = f"{LABEL_FILE}\nbuilt with ld65\n"
+ assert set(read_labels(write_labels(tmp_path, text))) == set(read_labels(write_labels(tmp_path, LABEL_FILE)))
+
+ def test_the_reported_layout_is_the_driver_s_own(self, tmp_path: Path) -> None:
+ addresses = read_addresses(write_labels(tmp_path, LABEL_FILE))
+ assert (addresses.load, addresses.init, addresses.play, addresses.song) == (
+ LOAD_ADDRESS,
+ INIT_ADDRESS,
+ PLAY_ADDRESS,
+ SONG_ADDRESS,
+ )
+
+ def test_a_missing_symbol_is_reported(self, tmp_path: Path) -> None:
+ text = "\n".join(line for line in LABEL_FILE.splitlines() if SONG_SYMBOL not in line)
+ with pytest.raises(DriverBuildError, match=SONG_SYMBOL):
+ read_addresses(write_labels(tmp_path, text))
diff --git a/tests/unit/sampletones_player/driver/assembler/test_toolchain.py b/tests/unit/sampletones_player/driver/assembler/test_toolchain.py
new file mode 100644
index 000000000..89602b787
--- /dev/null
+++ b/tests/unit/sampletones_player/driver/assembler/test_toolchain.py
@@ -0,0 +1,44 @@
+import shutil
+import sys
+from pathlib import Path
+from typing import Final
+
+import pytest
+
+from sampletones_player.driver.assembler.toolchain import (
+ ASSEMBLER,
+ INSTALL_HINTS,
+ LINKER,
+ Toolchain,
+)
+from sampletones_shared.exceptions import DriverBuildError, ToolchainMissingError
+from sampletones_shared.utils.system.system import System
+from tests.suite.base import BaseTestSuite
+
+FAILING_PROGRAM: Final[str] = "import sys; sys.stderr.write('boom'); sys.exit(1)"
+
+
+class TestTheToolchainALocatedBuildRuns(BaseTestSuite):
+ """The cc65 programs a build resolves before it produces anything."""
+
+ def test_every_system_states_how_it_installs_cc65(self) -> None:
+ assert set(INSTALL_HINTS) == set(System)
+
+ def test_a_located_program_answers_with_its_path(self, monkeypatch: pytest.MonkeyPatch) -> None:
+ installed = Path("/usr/local/bin") / ASSEMBLER
+ monkeypatch.setattr(shutil, "which", lambda program: str(installed))
+ assert Toolchain.locate() == Toolchain(assembler=installed, linker=installed)
+
+ def test_a_missing_program_names_itself(self, monkeypatch: pytest.MonkeyPatch) -> None:
+ monkeypatch.setattr(shutil, "which", lambda program: None)
+ with pytest.raises(ToolchainMissingError, match=ASSEMBLER):
+ Toolchain.locate()
+
+ def test_a_missing_program_states_how_this_system_installs_it(self, monkeypatch: pytest.MonkeyPatch) -> None:
+ monkeypatch.setattr(shutil, "which", lambda program: None)
+ with pytest.raises(ToolchainMissingError, match=INSTALL_HINTS[System.current()]):
+ Toolchain.find(LINKER)
+
+ def test_a_program_that_fails_carries_what_it_printed(self) -> None:
+ with pytest.raises(DriverBuildError, match="boom"):
+ Toolchain.run([sys.executable, "-c", FAILING_PROGRAM])
diff --git a/tests/unit/sampletones_player/driver/test_addresses.py b/tests/unit/sampletones_player/driver/test_addresses.py
new file mode 100644
index 000000000..91d4b195d
--- /dev/null
+++ b/tests/unit/sampletones_player/driver/test_addresses.py
@@ -0,0 +1,37 @@
+from typing import Final
+
+from sampletones_player.driver.addresses import DriverAddresses
+from sampletones_player.specification.driver import (
+ INIT_ADDRESS,
+ JUMP_INSTRUCTION_SIZE,
+ LOAD_ADDRESS,
+ PLAY_ADDRESS,
+)
+from sampletones_player.specification.nsf import PROGRAM_START
+from tests.suite.base import BaseTestSuite
+
+CODE_LENGTH: Final[int] = 512
+
+
+class TestTheDeclaredAddresses(BaseTestSuite):
+ """The addresses a driver answers at, which hold whatever the build produces."""
+
+ def test_the_image_loads_where_the_program_area_begins(self) -> None:
+ assert DriverAddresses.for_code(CODE_LENGTH).load == PROGRAM_START
+
+ def test_the_routines_answer_where_the_specification_states(self) -> None:
+ addresses = DriverAddresses.for_code(CODE_LENGTH)
+ assert (addresses.init, addresses.play) == (INIT_ADDRESS, PLAY_ADDRESS)
+
+ def test_the_entry_points_sit_one_jump_apart(self) -> None:
+ addresses = DriverAddresses.for_code(CODE_LENGTH)
+ assert addresses.play - addresses.init == JUMP_INSTRUCTION_SIZE
+
+ def test_the_song_follows_the_code(self) -> None:
+ assert DriverAddresses.for_code(CODE_LENGTH).song == LOAD_ADDRESS + CODE_LENGTH
+
+ def test_a_longer_driver_moves_the_song_alone(self) -> None:
+ shorter = DriverAddresses.for_code(CODE_LENGTH)
+ longer = DriverAddresses.for_code(CODE_LENGTH * 2)
+ assert (longer.load, longer.init, longer.play) == (shorter.load, shorter.init, shorter.play)
+ assert longer.song > shorter.song
diff --git a/tests/unit/sampletones_player/driver/test_image.py b/tests/unit/sampletones_player/driver/test_image.py
new file mode 100644
index 000000000..a9151e1b0
--- /dev/null
+++ b/tests/unit/sampletones_player/driver/test_image.py
@@ -0,0 +1,75 @@
+from typing import Any, Dict, Final
+
+import pytest
+
+from sampletones_player.driver.addresses import DriverAddresses
+from sampletones_player.driver.image import DriverImage
+from sampletones_player.specification.driver import (
+ INIT_ADDRESS,
+ JUMP_ABSOLUTE_OPCODE,
+ LOAD_ADDRESS,
+ MAX_ADDRESS,
+ PLAY_ADDRESS,
+)
+from tests.suite.base import BaseTestSuite
+
+RETURN_OPCODE: Final[int] = 0x60
+JUMP_TABLE: Final[bytes] = bytes((JUMP_ABSOLUTE_OPCODE, 0x00, 0x80, JUMP_ABSOLUTE_OPCODE, 0x00, 0x80))
+CODE: Final[bytes] = JUMP_TABLE + bytes((RETURN_OPCODE,))
+
+
+def image_fields(code: bytes = CODE, **overrides: int) -> Dict[str, Any]:
+ addresses = {
+ "load": LOAD_ADDRESS,
+ "init": INIT_ADDRESS,
+ "play": PLAY_ADDRESS,
+ "song": LOAD_ADDRESS + len(code),
+ }
+ addresses.update(overrides)
+ return {"code": code, "addresses": DriverAddresses(**addresses)}
+
+
+class TestTheCommittedDriver(BaseTestSuite):
+ """The driver the package ships, held to the contract the exporter reads it through."""
+
+ def test_the_driver_loads(self) -> None:
+ assert DriverImage.load().code
+
+ def test_the_song_begins_where_the_code_ends(self) -> None:
+ image = DriverImage.load()
+ assert image.addresses.song == image.addresses.load + len(image.code)
+
+ def test_the_routines_answer_where_the_specification_states(self) -> None:
+ image = DriverImage.load()
+ assert (image.addresses.init, image.addresses.play) == (INIT_ADDRESS, PLAY_ADDRESS)
+
+ def test_the_image_leads_with_a_jump_to_each_routine(self) -> None:
+ assert DriverImage.load().code[: len(JUMP_TABLE) : 3] == bytes((JUMP_ABSOLUTE_OPCODE,) * 2)
+
+
+class TestTheImageContract(BaseTestSuite):
+ """What an image must lay out for the exporter to place a song behind the driver."""
+
+ def test_a_song_address_past_the_code_is_rejected(self) -> None:
+ with pytest.raises(ValueError, match="the song belongs at"):
+ DriverImage(**image_fields(song=LOAD_ADDRESS + len(CODE) + 1))
+
+ def test_a_play_routine_outside_the_code_is_rejected(self) -> None:
+ with pytest.raises(ValueError, match="the play routine lies at"):
+ DriverImage(**image_fields(play=LOAD_ADDRESS + len(CODE)))
+
+ def test_an_init_routine_before_the_load_address_is_rejected(self) -> None:
+ with pytest.raises(ValueError, match="the init routine lies at"):
+ DriverImage(**image_fields(init=LOAD_ADDRESS - 1))
+
+ def test_an_image_that_leads_with_anything_but_a_jump_is_rejected(self) -> None:
+ with pytest.raises(ValueError, match="rather than the jump"):
+ DriverImage(**image_fields(code=bytes((RETURN_OPCODE,)) * len(CODE)))
+
+ def test_an_empty_image_is_rejected(self) -> None:
+ with pytest.raises(ValueError):
+ DriverImage(**image_fields(code=b""))
+
+ def test_an_address_past_the_bus_is_rejected(self) -> None:
+ with pytest.raises(ValueError):
+ DriverImage(**image_fields(play=MAX_ADDRESS + 1))
diff --git a/tests/unit/sampletones_player/driver/test_song_include.py b/tests/unit/sampletones_player/driver/test_song_include.py
new file mode 100644
index 000000000..042e4b300
--- /dev/null
+++ b/tests/unit/sampletones_player/driver/test_song_include.py
@@ -0,0 +1,140 @@
+import ast
+from pathlib import Path
+from typing import Dict, Final
+
+import pytest
+
+from sampletones_player.compression.pitch import PITCH_COUNT
+from sampletones_player.compression.planes.order import PlaneOrder
+from sampletones_player.driver.assembler.layout import INCLUDE_DIRECTORY
+from sampletones_player.specification.binary import WORD_SIZE
+from sampletones_player.specification.compression import (
+ OPCODE_SIZE,
+ PHRASE_ID_ESCAPE,
+ PHRASE_LENGTH_SIZE,
+ PHRASE_TABLE_COUNT_SIZE,
+ PHRASE_TABLE_ENTRY_SIZE,
+ PLANE_COUNT,
+ PLANE_STATE_SIZE,
+ TOKEN_OPERAND_MASK,
+ TOKEN_TAG_MASK,
+ TokenTag,
+)
+from sampletones_player.specification.song import (
+ LOOP_ENTRIES_OFFSET,
+ LOOP_TICK_OFFSET,
+ NO_LOOP,
+ PHRASE_TABLE_OFFSET,
+ STEP_FRACTION_OFFSET,
+ STEP_WHOLE_OFFSET,
+ STREAM_OFFSETS_OFFSET,
+ TIMER_TABLE_OFFSET,
+ TOTAL_TICKS_OFFSET,
+)
+
+SONG_INCLUDE: Final[str] = "song.inc"
+HEXADECIMAL_MARKER: Final[str] = "$"
+HEXADECIMAL_PREFIX: Final[str] = "0x"
+ASSIGNMENT: Final[str] = "="
+
+STATED: Final[Dict[str, int]] = {
+ "WORD_SIZE": WORD_SIZE,
+ "PLANE_COUNT": PLANE_COUNT,
+ "STEP_WHOLE_OFFSET": STEP_WHOLE_OFFSET,
+ "STEP_FRACTION_OFFSET": STEP_FRACTION_OFFSET,
+ "TOTAL_TICKS_OFFSET": TOTAL_TICKS_OFFSET,
+ "LOOP_TICK_OFFSET": LOOP_TICK_OFFSET,
+ "TIMER_TABLE_OFFSET": TIMER_TABLE_OFFSET,
+ "PHRASE_TABLE_OFFSET": PHRASE_TABLE_OFFSET,
+ "STREAM_OFFSETS_OFFSET": STREAM_OFFSETS_OFFSET,
+ "LOOP_ENTRIES_OFFSET": LOOP_ENTRIES_OFFSET,
+ "NO_LOOP": NO_LOOP,
+ "PITCH_COUNT": PITCH_COUNT,
+ "TOKEN_TAG_MASK": TOKEN_TAG_MASK,
+ "TOKEN_OPERAND_MASK": TOKEN_OPERAND_MASK,
+ "TAG_HOLD": TokenTag.HOLD,
+ "TAG_LITERAL": TokenTag.LITERAL,
+ "TAG_PHRASE": TokenTag.PHRASE,
+ "TAG_TRANSPOSED_PHRASE": TokenTag.TRANSPOSED_PHRASE,
+ "OPCODE_SIZE": OPCODE_SIZE,
+ "PHRASE_ID_ESCAPE": PHRASE_ID_ESCAPE,
+ "PHRASE_TABLE_COUNT_SIZE": PHRASE_TABLE_COUNT_SIZE,
+ "PHRASE_TABLE_ENTRY_SIZE": PHRASE_TABLE_ENTRY_SIZE,
+ "PHRASE_LENGTH_SIZE": PHRASE_LENGTH_SIZE,
+ "PLANE_STATE_SIZE": PLANE_STATE_SIZE,
+ "PLANE_STATE_BYTES": PLANE_COUNT * PLANE_STATE_SIZE,
+}
+
+
+def _value(node: ast.expr, defined: Dict[str, int]) -> int:
+ """The number an equate's expression comes to, over the equates before it."""
+ match node:
+ case ast.Constant(value=int() as number):
+ return number
+ case ast.Name(id=name):
+ return defined[name]
+ case ast.BinOp(left=left, op=ast.Add(), right=right):
+ return _value(left, defined) + _value(right, defined)
+ case ast.BinOp(left=left, op=ast.Mult(), right=right):
+ return _value(left, defined) * _value(right, defined)
+
+ raise ValueError(f"an equate reads {ast.dump(node)}, which the include holds no form for")
+
+
+def read_equates(path: Path) -> Dict[str, int]:
+ """Reads the constants an assembly include states, each over the ones stated before it.
+
+ The driver and the exporter read one song block, so what the assembly believes about the
+ layout is held against what the specification states. An include line is ``NAME = value``,
+ where the value is a number, another equate, or the two joined by an addition or a product.
+
+ Args:
+ path: The include file to read.
+
+ Returns:
+ Dict[str, int]: The value each equate comes to.
+ """
+ defined: Dict[str, int] = {}
+ for line in path.read_text(encoding="utf-8").splitlines():
+ if ASSIGNMENT not in line:
+ continue
+
+ name, expression = line.split(ASSIGNMENT, 1)
+ parsed = ast.parse(expression.strip().replace(HEXADECIMAL_MARKER, HEXADECIMAL_PREFIX), mode="eval")
+ defined[name.strip()] = _value(parsed.body, defined)
+
+ return defined
+
+
+@pytest.fixture(name="equates", scope="module")
+def equates_fixture() -> Dict[str, int]:
+ return read_equates(INCLUDE_DIRECTORY / SONG_INCLUDE)
+
+
+class TestTheDriverReadsTheBlockTheExporterWrites:
+ """Every figure the assembly reads the song block by, held against the specification."""
+
+ @pytest.mark.parametrize("name", sorted(STATED), ids=sorted(STATED))
+ def test_the_include_states_what_the_specification_states(
+ self,
+ name: str,
+ equates: Dict[str, int],
+ ) -> None:
+ assert equates[name] == STATED[name]
+
+ def test_the_plane_state_fields_fill_the_block_each_plane_holds(
+ self,
+ equates: Dict[str, int],
+ ) -> None:
+ """A plane's decoder state is read by field, so the fields cover the block and no more."""
+ fields = ("PLANE_SOURCE", "PLANE_PHRASE", "PLANE_PHRASE_TICKS", "PLANE_TOKEN_TICKS")
+ stated = ("PLANE_VALUE", "PLANE_SHIFT")
+ offsets = [equates[field] for field in (*fields, *stated)]
+ assert offsets == sorted(offsets)
+ assert max(offsets) < equates["PLANE_STATE_SIZE"]
+
+ def test_every_plane_is_named_at_its_own_state_block(self, equates: Dict[str, int]) -> None:
+ """The assembly holds a state block per plane, named and ordered as the song block is."""
+ planes = [f"{name.upper()}_PLANE" for name in PlaneOrder.names()]
+ expected = [plane * equates["PLANE_STATE_SIZE"] for plane in range(PLANE_COUNT)]
+ assert [equates[plane] for plane in planes] == expected
diff --git a/tests/unit/sampletones_player/nsf/__init__.py b/tests/unit/sampletones_player/nsf/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/tests/unit/sampletones_player/nsf/test_file.py b/tests/unit/sampletones_player/nsf/test_file.py
new file mode 100644
index 000000000..988dc8a7e
--- /dev/null
+++ b/tests/unit/sampletones_player/nsf/test_file.py
@@ -0,0 +1,164 @@
+import struct
+from pathlib import Path
+from typing import Final
+
+import pytest
+
+from sampletones_player.driver.image import DriverImage
+from sampletones_player.nsf.file import nsf_to_bytes, write_nsf
+from sampletones_player.nsf.header import header_to_bytes
+from sampletones_player.nsf.information import NSFInformation
+from sampletones_player.nsf.song import song_to_bytes
+from sampletones_player.song import Song
+from sampletones_player.specification.nsf import (
+ HEADER_SIZE,
+ LOAD_ADDRESS_OFFSET,
+ PROGRAM_SIZE,
+)
+from sampletones_shared.exceptions import SongTooLargeError
+from tests.suite.player import (
+ PLAYER_FULL_VOLUME,
+ PLAYER_REFERENCE_TIMER,
+ PLAYER_SILENT_VOLUME,
+ player_song,
+ pulse_tick,
+ resting_streams,
+ spelled_song,
+)
+
+NTSC_FREQUENCY: Final[int] = 60
+FILENAME: Final[str] = "song.nsf"
+INFORMATION: Final[NSFInformation] = NSFInformation(
+ title="Amen",
+ artist="Author",
+)
+
+SOUNDING: Final = pulse_tick(
+ PLAYER_FULL_VOLUME,
+ 0,
+ PLAYER_REFERENCE_TIMER,
+)
+RESTING: Final = pulse_tick(
+ PLAYER_SILENT_VOLUME,
+ 0,
+ PLAYER_REFERENCE_TIMER,
+)
+
+
+@pytest.fixture(scope="module")
+def image() -> DriverImage:
+ return DriverImage.load()
+
+
+@pytest.fixture
+def song() -> Song:
+ return player_song(
+ resting_streams((SOUNDING, RESTING)),
+ NTSC_FREQUENCY,
+ loop_tick=None,
+ )
+
+
+def oversized_song(image: DriverImage) -> Song:
+ return spelled_song(
+ PROGRAM_SIZE - len(image.code),
+ NTSC_FREQUENCY,
+ )
+
+
+class TestNSFBytes:
+ """The three parts a console loads, in the order it loads them."""
+
+ def test_the_file_leads_with_its_header(
+ self,
+ song: Song,
+ image: DriverImage,
+ ) -> None:
+ assert nsf_to_bytes(song, INFORMATION, image)[:HEADER_SIZE] == header_to_bytes(
+ INFORMATION,
+ image.addresses,
+ )
+
+ def test_the_driver_follows_the_header(
+ self,
+ song: Song,
+ image: DriverImage,
+ ) -> None:
+ assert nsf_to_bytes(song, INFORMATION, image)[HEADER_SIZE : HEADER_SIZE + len(image.code)] == image.code
+
+ def test_the_song_follows_the_driver(
+ self,
+ song: Song,
+ image: DriverImage,
+ ) -> None:
+ data = nsf_to_bytes(song, INFORMATION, image)
+ assert data[HEADER_SIZE + len(image.code) :] == song_to_bytes(
+ song,
+ PROGRAM_SIZE - len(image.code),
+ )
+
+ def test_the_file_is_its_three_parts_and_nothing_more(
+ self,
+ song: Song,
+ image: DriverImage,
+ ) -> None:
+ block = song_to_bytes(song, PROGRAM_SIZE - len(image.code))
+ assert len(nsf_to_bytes(song, INFORMATION, image)) == HEADER_SIZE + len(image.code) + len(block)
+
+ def test_the_loaded_image_fits_the_program_area(
+ self,
+ song: Song,
+ image: DriverImage,
+ ) -> None:
+ assert len(nsf_to_bytes(song, INFORMATION, image)) - HEADER_SIZE <= PROGRAM_SIZE
+
+ def test_the_header_loads_the_image_where_the_driver_expects_it(
+ self,
+ song: Song,
+ image: DriverImage,
+ ) -> None:
+ data = nsf_to_bytes(song, INFORMATION, image)
+ assert struct.unpack_from(" None:
+ data = nsf_to_bytes(song, INFORMATION, image)
+ block = song_to_bytes(song, PROGRAM_SIZE - len(image.code))
+ song_start = len(data) - len(block)
+ assert image.addresses.load + song_start - HEADER_SIZE == image.addresses.song
+
+
+class TestSongsBeyondTheProgramArea:
+ """A song outgrowing the room behind the driver names the overflow."""
+
+ def test_a_song_too_large_for_the_program_area_raises(
+ self,
+ image: DriverImage,
+ ) -> None:
+ with pytest.raises(SongTooLargeError):
+ nsf_to_bytes(
+ oversized_song(image),
+ INFORMATION,
+ image,
+ )
+
+
+class TestWriteNSF:
+ """The bytes reaching a file on disk."""
+
+ def test_the_file_holds_the_bytes_the_song_serializes_to(
+ self,
+ song: Song,
+ image: DriverImage,
+ tmp_path: Path,
+ ) -> None:
+ destination = tmp_path / FILENAME
+ write_nsf(destination, song, INFORMATION, image)
+ assert destination.read_bytes() == nsf_to_bytes(
+ song,
+ INFORMATION,
+ image,
+ )
diff --git a/tests/unit/sampletones_player/nsf/test_header.py b/tests/unit/sampletones_player/nsf/test_header.py
new file mode 100644
index 000000000..c8a0fbd84
--- /dev/null
+++ b/tests/unit/sampletones_player/nsf/test_header.py
@@ -0,0 +1,190 @@
+import struct
+from dataclasses import dataclass
+from fractions import Fraction
+from typing import Final, Tuple
+
+import pytest
+
+from sampletones_player.driver.addresses import DriverAddresses
+from sampletones_player.nsf.header import header_to_bytes
+from sampletones_player.nsf.information import NSFInformation
+from sampletones_player.specification.clock import (
+ MICROSECONDS_PER_SECOND,
+ NTSC_FRAME_RATE,
+)
+from sampletones_player.specification.nsf import (
+ ARTIST_OFFSET,
+ BANKSWITCH_OFFSET,
+ BANKSWITCH_SIZE,
+ COPYRIGHT_OFFSET,
+ EXPANSION_OFFSET,
+ FIRST_SONG,
+ FIRST_SONG_OFFSET,
+ HEADER_SIZE,
+ INIT_ADDRESS_OFFSET,
+ LOAD_ADDRESS_OFFSET,
+ MAGIC_OFFSET,
+ NO_EXPANSION_CHIPS,
+ NO_NSF2_FEATURES,
+ NSF2_FEATURES_OFFSET,
+ NSF2_LENGTH_OFFSET,
+ NSF2_LENGTH_SIZE,
+ NSF_MAGIC,
+ NSF_VERSION,
+ NTSC_PERIOD_OFFSET,
+ NTSC_PLAY_PERIOD_MICROSECONDS,
+ NTSC_REGION,
+ PAL_PERIOD_OFFSET,
+ PAL_PLAY_PERIOD_MICROSECONDS,
+ PLAY_ADDRESS_OFFSET,
+ REGION_OFFSET,
+ SONG_COUNT,
+ SONG_COUNT_OFFSET,
+ STRING_FIELD_SIZE,
+ TITLE_OFFSET,
+ VERSION_OFFSET,
+)
+from sampletones_shared.application import SAMPLETONES_COPYRIGHT
+from tests.suite.base import BaseTestSuite
+from tests.suite.case import BaseAutolabelTestCase
+
+SONG_ADDRESS: Final[int] = 0x8153
+TITLE: Final[str] = "Amen"
+ARTIST: Final[str] = "Jakim"
+ADDRESSES: Final[DriverAddresses] = DriverAddresses(song=SONG_ADDRESS)
+INFORMATION: Final[NSFInformation] = NSFInformation(title=TITLE, artist=ARTIST)
+RATE_TOLERANCE: Final[Fraction] = Fraction(1, 10_000)
+
+
+def header() -> bytes:
+ return header_to_bytes(INFORMATION, ADDRESSES)
+
+
+def read_word(data: bytes, offset: int) -> int:
+ return int(struct.unpack_from(" bytes:
+ return data[offset : offset + STRING_FIELD_SIZE]
+
+
+class TestHeaderBytes:
+ """The exact bytes an NSF header serializes to.
+
+ The layout is what every console player reads a file through, so the literal states it in
+ full: the identity, the three addresses, the three text fields, and the playback fields
+ behind them.
+ """
+
+ EXPECTED: Final[bytes] = (
+ NSF_MAGIC
+ + b"\x01\x01\x01"
+ + b"\x00\x80\x00\x80\x03\x80"
+ + TITLE.encode("utf-8").ljust(STRING_FIELD_SIZE, b"\x00")
+ + ARTIST.encode("utf-8").ljust(STRING_FIELD_SIZE, b"\x00")
+ + SAMPLETONES_COPYRIGHT.encode("utf-8").ljust(STRING_FIELD_SIZE, b"\x00")
+ + b"\xff\x40"
+ + bytes(BANKSWITCH_SIZE)
+ + b"\x20\x4e"
+ + b"\x00\x00\x00"
+ + bytes(NSF2_LENGTH_SIZE)
+ )
+
+ def test_the_header_serializes_to_the_expected_bytes(self) -> None:
+ assert header() == self.EXPECTED
+
+ def test_the_header_fills_the_program_area_it_precedes(self) -> None:
+ assert len(header()) == HEADER_SIZE
+
+
+class TestHeaderIdentity:
+ """What names the file an NSF, and the one tune it carries."""
+
+ def test_the_header_leads_with_the_magic(self) -> None:
+ assert header()[MAGIC_OFFSET : MAGIC_OFFSET + len(NSF_MAGIC)] == NSF_MAGIC
+
+ def test_the_header_states_its_version(self) -> None:
+ assert header()[VERSION_OFFSET] == NSF_VERSION
+
+ def test_the_file_carries_one_tune(self) -> None:
+ assert header()[SONG_COUNT_OFFSET] == SONG_COUNT
+
+ def test_the_tune_it_opens_on_is_the_one_it_carries(self) -> None:
+ assert header()[FIRST_SONG_OFFSET] == FIRST_SONG == SONG_COUNT
+
+
+class TestHeaderRoutines:
+ """The addresses a console loads the image at and drives it through."""
+
+ def test_the_load_address_is_where_the_driver_loads(self) -> None:
+ assert read_word(header(), LOAD_ADDRESS_OFFSET) == ADDRESSES.load
+
+ def test_the_init_address_is_the_drivers_own(self) -> None:
+ assert read_word(header(), INIT_ADDRESS_OFFSET) == ADDRESSES.init
+
+ def test_the_play_address_is_the_drivers_own(self) -> None:
+ assert read_word(header(), PLAY_ADDRESS_OFFSET) == ADDRESSES.play
+
+
+class TestHeaderStrings(BaseTestSuite):
+ """The three text fields, each written into a fixed field and padded out with NULs."""
+
+ @dataclass(frozen=True, kw_only=True)
+ class TestCase(BaseAutolabelTestCase):
+ offset: int
+ expected: str
+
+ @property
+ def label(self) -> str:
+ return self.expected
+
+ test_cases = (
+ TestCase(offset=TITLE_OFFSET, expected=TITLE),
+ TestCase(offset=ARTIST_OFFSET, expected=ARTIST),
+ TestCase(offset=COPYRIGHT_OFFSET, expected=SAMPLETONES_COPYRIGHT),
+ )
+
+ @pytest.mark.parametrize("test_case", test_cases, ids=lambda case: case.label)
+ def test_the_field_carries_its_text(self, test_case: TestCase) -> None:
+ field = read_string(header(), test_case.offset)
+ assert field == test_case.expected.encode("utf-8").ljust(STRING_FIELD_SIZE, b"\x00")
+
+ def test_text_longer_than_the_field_is_written_as_much_as_fits(self) -> None:
+ overlong = "A" * (STRING_FIELD_SIZE * 2)
+ data = header_to_bytes(NSFInformation(title=overlong, artist=ARTIST), ADDRESSES)
+ assert read_string(data, TITLE_OFFSET) == overlong.encode("utf-8")[:STRING_FIELD_SIZE]
+
+ def test_the_fields_stand_back_to_back(self) -> None:
+ assert (ARTIST_OFFSET - TITLE_OFFSET, COPYRIGHT_OFFSET - ARTIST_OFFSET) == (
+ STRING_FIELD_SIZE,
+ STRING_FIELD_SIZE,
+ )
+
+
+class TestHeaderPlayback:
+ """The rate the console drives the file at, and the hardware it asks for."""
+
+ def test_the_ntsc_period_is_the_one_the_specification_names(self) -> None:
+ assert read_word(header(), NTSC_PERIOD_OFFSET) == NTSC_PLAY_PERIOD_MICROSECONDS
+
+ def test_the_ntsc_period_asks_for_the_rate_the_schedule_counts_in(self) -> None:
+ """A player reading the field and one driving from the frame run a stream at one speed."""
+ requested = Fraction(MICROSECONDS_PER_SECOND, read_word(header(), NTSC_PERIOD_OFFSET))
+ assert abs(requested - NTSC_FRAME_RATE) / NTSC_FRAME_RATE < RATE_TOLERANCE
+
+ def test_the_pal_period_states_the_fiftieth_of_a_second(self) -> None:
+ assert read_word(header(), PAL_PERIOD_OFFSET) == PAL_PLAY_PERIOD_MICROSECONDS
+
+ def test_the_image_loads_whole(self) -> None:
+ assert header()[BANKSWITCH_OFFSET : BANKSWITCH_OFFSET + BANKSWITCH_SIZE] == bytes(BANKSWITCH_SIZE)
+
+ def test_the_tune_is_ntsc(self) -> None:
+ assert header()[REGION_OFFSET] == NTSC_REGION
+
+ def test_the_tune_plays_on_the_2a03_alone(self) -> None:
+ assert header()[EXPANSION_OFFSET] == NO_EXPANSION_CHIPS
+
+ def test_the_header_states_the_first_nsf_version(self) -> None:
+ trailing: Tuple[int, ...] = tuple(header()[NSF2_LENGTH_OFFSET : NSF2_LENGTH_OFFSET + NSF2_LENGTH_SIZE])
+ assert header()[NSF2_FEATURES_OFFSET] == NO_NSF2_FEATURES
+ assert trailing == (0,) * NSF2_LENGTH_SIZE
diff --git a/tests/unit/sampletones_player/nsf/test_layout.py b/tests/unit/sampletones_player/nsf/test_layout.py
new file mode 100644
index 000000000..c87bb2a99
--- /dev/null
+++ b/tests/unit/sampletones_player/nsf/test_layout.py
@@ -0,0 +1,107 @@
+from typing import Final
+
+from sampletones_player.nsf.layout import SongLayout
+from sampletones_player.song import Song
+from sampletones_player.specification.compression import (
+ PHRASE_LENGTH_SIZE,
+ PHRASE_TABLE_COUNT_SIZE,
+ PHRASE_TABLE_ENTRY_SIZE,
+ PLANE_COUNT,
+)
+from sampletones_player.specification.song import SONG_HEADER_SIZE
+from tests.suite.player import (
+ PLAYER_FULL_VOLUME,
+ PLAYER_OCTAVE_UP_TIMER,
+ PLAYER_REFERENCE_TIMER,
+ PLAYER_SILENT_VOLUME,
+ player_song,
+ pulse_tick,
+ resting_streams,
+)
+
+NTSC_FREQUENCY: Final[int] = 60
+LOOP_TICK: Final[int] = 2
+FIGURE_REPEATS: Final[int] = 8
+
+SOUNDING: Final = pulse_tick(PLAYER_FULL_VOLUME, 0, PLAYER_REFERENCE_TIMER)
+RESTING: Final = pulse_tick(PLAYER_SILENT_VOLUME, 0, PLAYER_REFERENCE_TIMER)
+OCTAVE_UP: Final = pulse_tick(PLAYER_FULL_VOLUME, 0, PLAYER_OCTAVE_UP_TIMER)
+
+FIGURE: Final = (SOUNDING, OCTAVE_UP, RESTING, OCTAVE_UP)
+
+
+def figure_song(loop_tick: int) -> Song:
+ return player_song(resting_streams(FIGURE * FIGURE_REPEATS), NTSC_FREQUENCY, loop_tick=loop_tick)
+
+
+class TestWhereEachPartOfTheBlockBegins:
+ """The parts follow one another in the order the block writes them, none of them overlapping."""
+
+ def test_the_timer_table_follows_the_header(self) -> None:
+ assert SongLayout.of(figure_song(LOOP_TICK)).timer_table == SONG_HEADER_SIZE
+
+ def test_the_dictionary_follows_the_timer_table(self) -> None:
+ song = figure_song(LOOP_TICK)
+ layout = SongLayout.of(song)
+ assert layout.phrase_table == layout.timer_table + len(song.pitches.data)
+
+ def test_the_first_body_follows_the_table_of_entries(self) -> None:
+ song = figure_song(LOOP_TICK)
+ layout = SongLayout.of(song)
+ entries = PHRASE_TABLE_COUNT_SIZE + PHRASE_TABLE_ENTRY_SIZE * len(song.planes.phrases)
+ assert layout.bodies[0] == layout.phrase_table + entries
+
+ def test_each_body_follows_the_one_before_it(self) -> None:
+ song = figure_song(LOOP_TICK)
+ layout = SongLayout.of(song)
+ for phrase, offset, following in zip(song.planes.phrases.phrases, layout.bodies, layout.bodies[1:]):
+ assert following == offset + PHRASE_LENGTH_SIZE + phrase.length
+
+ def test_the_first_stream_follows_the_last_body(self) -> None:
+ song = figure_song(LOOP_TICK)
+ layout = SongLayout.of(song)
+ last = song.planes.phrases[len(song.planes.phrases) - 1]
+ assert layout.streams[0] == layout.bodies[-1] + PHRASE_LENGTH_SIZE + last.length
+
+ def test_each_stream_follows_the_one_before_it(self) -> None:
+ song = figure_song(LOOP_TICK)
+ layout = SongLayout.of(song)
+ for stream, offset, following in zip(song.planes.streams, layout.streams, layout.streams[1:]):
+ assert following == offset + len(stream)
+
+ def test_the_block_ends_behind_the_last_stream(self) -> None:
+ song = figure_song(LOOP_TICK)
+ layout = SongLayout.of(song)
+ assert layout.size == layout.streams[-1] + len(song.planes.streams[-1])
+
+
+class TestWhereEachStreamIsReEntered:
+ """A loop entry stands inside the stream it belongs to, at the token its tick starts."""
+
+ def test_every_plane_states_an_entry(self) -> None:
+ assert len(SongLayout.of(figure_song(LOOP_TICK)).loop_entries) == PLANE_COUNT
+
+ def test_an_entry_stands_at_the_token_the_loop_tick_starts(self) -> None:
+ song = figure_song(LOOP_TICK)
+ layout = SongLayout.of(song)
+ entered = song.planes.entries(LOOP_TICK)
+ assert layout.loop_entries == tuple(offset + entry for offset, entry in zip(layout.streams, entered))
+
+ def test_a_song_that_stops_re_enters_at_each_streams_own_start(self) -> None:
+ song = player_song(resting_streams(FIGURE * FIGURE_REPEATS), NTSC_FREQUENCY, loop_tick=None)
+ layout = SongLayout.of(song)
+ assert layout.loop_entries == layout.streams
+
+
+class TestWhatTheBlockStates:
+ """Every offset written into the block is named, so an overflow says which part reached past."""
+
+ def test_every_part_of_the_block_is_named(self) -> None:
+ song = figure_song(LOOP_TICK)
+ layout = SongLayout.of(song)
+ expected = 2 + len(song.planes.phrases) + 2 * PLANE_COUNT
+ assert len(layout.stated) == expected
+
+ def test_every_stated_offset_lies_inside_the_block(self) -> None:
+ layout = SongLayout.of(figure_song(LOOP_TICK))
+ assert all(0 < offset < layout.size for _, offset in layout.stated)
diff --git a/tests/unit/sampletones_player/nsf/test_song.py b/tests/unit/sampletones_player/nsf/test_song.py
new file mode 100644
index 000000000..9e1b7baf4
--- /dev/null
+++ b/tests/unit/sampletones_player/nsf/test_song.py
@@ -0,0 +1,281 @@
+import struct
+from dataclasses import dataclass
+from typing import Final, Tuple
+
+import pytest
+
+from sampletones_player.compression.planes.order import PlaneOrder
+from sampletones_player.nsf.layout import NAME_SEPARATOR, SongLayout
+from sampletones_player.nsf.song import song_to_bytes
+from sampletones_player.song import Song
+from sampletones_player.specification.binary import WORD_SIZE
+from sampletones_player.specification.compression import PLANE_COUNT
+from sampletones_player.specification.song import (
+ LOOP_ENTRIES_OFFSET,
+ LOOP_TICK_OFFSET,
+ MAX_BLOCK_OFFSET,
+ NO_LOOP,
+ PHRASE_TABLE_OFFSET,
+ SONG_HEADER_SIZE,
+ STEP_FRACTION_OFFSET,
+ STEP_WHOLE_OFFSET,
+ STREAM_OFFSETS_OFFSET,
+ TIMER_TABLE_OFFSET,
+ TOTAL_TICKS_OFFSET,
+)
+from sampletones_shared.exceptions import SongTooLargeError
+from tests.suite.base import BaseTestSuite
+from tests.suite.case import BaseAutolabelTestCase
+from tests.suite.player import (
+ PLAYER_FULL_VOLUME,
+ PLAYER_OCTAVE_UP_TIMER,
+ PLAYER_REFERENCE_TIMER,
+ PLAYER_SILENT_VOLUME,
+ player_song,
+ pulse_tick,
+ resting_streams,
+ spelled_song,
+)
+
+NTSC_FREQUENCY: Final[int] = 60
+HALF_RATE_FREQUENCY: Final[int] = 30
+PROGRAM_AREA_BYTES: Final[int] = 0x8000
+LOOP_TICK: Final[int] = 2
+
+SOUNDING: Final = pulse_tick(PLAYER_FULL_VOLUME, 0, PLAYER_REFERENCE_TIMER)
+RESTING: Final = pulse_tick(PLAYER_SILENT_VOLUME, 0, PLAYER_REFERENCE_TIMER)
+OCTAVE_UP: Final = pulse_tick(PLAYER_FULL_VOLUME, 0, PLAYER_OCTAVE_UP_TIMER)
+
+
+def read_word(data: bytes, offset: int) -> int:
+ return int(struct.unpack_from(" Tuple[int, ...]:
+ return tuple(read_word(data, offset + WORD_SIZE * field) for field in range(count))
+
+
+def stream_offsets(data: bytes) -> Tuple[int, ...]:
+ return read_words(data, STREAM_OFFSETS_OFFSET, PLANE_COUNT)
+
+
+def loop_entries(data: bytes) -> Tuple[int, ...]:
+ return read_words(data, LOOP_ENTRIES_OFFSET, PLANE_COUNT)
+
+
+def two_tick_song(nes_frequency: int) -> Song:
+ return player_song(resting_streams((SOUNDING, RESTING)), nes_frequency, loop_tick=None)
+
+
+def repeating_song() -> Song:
+ return player_song(
+ resting_streams((SOUNDING, OCTAVE_UP, RESTING, SOUNDING)),
+ NTSC_FREQUENCY,
+ loop_tick=LOOP_TICK,
+ )
+
+
+class TestSongBytes:
+ """The exact bytes a hand-built song serializes to.
+
+ The layout is the contract the driver reads the song through, so the literal states it in
+ full: the header, the timer every pitch sounds at, the dictionary the tokens name, and one
+ token stream per plane. The timer table is named rather than transcribed, since it is the
+ tuning's own table and the block carries whatever that table holds.
+ """
+
+ EXPECTED_HEADER: Final[bytes] = (
+ b"\x00"
+ b"\xca\x7f"
+ b"\x02\x00"
+ b"\xff\xff"
+ b"\x37\x00"
+ b"\x07\x01"
+ b"\x08\x01\x0b\x01\x0e\x01"
+ b"\x11\x01\x14\x01\x17\x01"
+ b"\x1a\x01\x1d\x01\x20\x01"
+ b"\x23\x01\x26\x01"
+ b"\x08\x01\x0b\x01\x0e\x01"
+ b"\x11\x01\x14\x01\x17\x01"
+ b"\x1a\x01\x1d\x01\x20\x01"
+ b"\x23\x01\x26\x01"
+ )
+
+ EXPECTED_STREAMS: Final[bytes] = (
+ b"\x00"
+ b"\x41\x3f\x30"
+ b"\x40\x21\x00"
+ b"\x40\x00\x00"
+ b"\x40\x30\x00"
+ b"\x40\x21\x00"
+ b"\x40\x00\x00"
+ b"\x40\x80\x00"
+ b"\x40\x21\x00"
+ b"\x40\x00\x00"
+ b"\x40\x30\x00"
+ b"\x40\x0a\x00"
+ )
+
+ def test_the_song_serializes_to_the_expected_bytes(self) -> None:
+ song = two_tick_song(HALF_RATE_FREQUENCY)
+ expected = self.EXPECTED_HEADER + song.pitches.data + self.EXPECTED_STREAMS
+ assert song_to_bytes(song, PROGRAM_AREA_BYTES) == expected
+
+ def test_the_header_runs_to_the_length_the_offsets_are_read_at(self) -> None:
+ assert len(self.EXPECTED_HEADER) == SONG_HEADER_SIZE
+
+
+class TestSongHeader(BaseTestSuite):
+ """Each header field carries the value its offset is read for."""
+
+ @dataclass(frozen=True, kw_only=True)
+ class TestCase(BaseAutolabelTestCase):
+ expected: int
+ name: str
+ nes_frequency: int
+
+ @property
+ def label(self) -> str:
+ return self.name
+
+ test_cases: Tuple[TestCase, ...] = (
+ TestCase(name="60hz-whole", nes_frequency=NTSC_FREQUENCY, expected=0),
+ TestCase(name="30hz-whole", nes_frequency=HALF_RATE_FREQUENCY, expected=0),
+ TestCase(name="120hz-whole", nes_frequency=120, expected=1),
+ TestCase(name="300hz-whole", nes_frequency=300, expected=4),
+ )
+
+ @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label)
+ def test_the_step_reaches_the_header_as_the_driver_holds_it(self, test_case: TestCase) -> None:
+ song = two_tick_song(test_case.nes_frequency)
+ data = song_to_bytes(song, PROGRAM_AREA_BYTES)
+ step = song.schedule.fixed_point_step
+ assert data[STEP_WHOLE_OFFSET] == test_case.expected
+ assert data[STEP_WHOLE_OFFSET] == step.whole
+ assert read_word(data, STEP_FRACTION_OFFSET) == step.fraction
+
+ def test_the_header_states_the_songs_length(self) -> None:
+ song = player_song(resting_streams((SOUNDING, OCTAVE_UP, RESTING)), NTSC_FREQUENCY, loop_tick=None)
+ data = song_to_bytes(song, PROGRAM_AREA_BYTES)
+ assert read_word(data, TOTAL_TICKS_OFFSET) == song.ticks
+
+ def test_a_song_that_stops_states_no_loop(self) -> None:
+ data = song_to_bytes(two_tick_song(NTSC_FREQUENCY), PROGRAM_AREA_BYTES)
+ assert read_word(data, LOOP_TICK_OFFSET) == NO_LOOP
+
+ def test_a_song_that_repeats_states_its_loop_tick(self) -> None:
+ data = song_to_bytes(repeating_song(), PROGRAM_AREA_BYTES)
+ assert read_word(data, LOOP_TICK_OFFSET) == LOOP_TICK
+
+
+class TestTimerTable:
+ """The timer every pitch sounds at, where the header says it lies."""
+
+ def test_the_table_begins_where_the_header_states(self) -> None:
+ song = two_tick_song(NTSC_FREQUENCY)
+ data = song_to_bytes(song, PROGRAM_AREA_BYTES)
+ offset = read_word(data, TIMER_TABLE_OFFSET)
+ assert data[offset : offset + len(song.pitches.data)] == song.pitches.data
+
+ def test_the_table_follows_the_header(self) -> None:
+ data = song_to_bytes(two_tick_song(NTSC_FREQUENCY), PROGRAM_AREA_BYTES)
+ assert read_word(data, TIMER_TABLE_OFFSET) == SONG_HEADER_SIZE
+
+
+class TestPhraseTable:
+ """The dictionary the tokens name, counted and then reached through its own offsets."""
+
+ def test_the_table_states_how_many_phrases_it_holds(self) -> None:
+ song = repeating_song()
+ data = song_to_bytes(song, PROGRAM_AREA_BYTES)
+ assert data[read_word(data, PHRASE_TABLE_OFFSET)] == len(song.planes.phrases)
+
+ def test_each_entry_reaches_that_phrases_length_and_body(self) -> None:
+ song = spelled_phrase_song()
+ data = song_to_bytes(song, PROGRAM_AREA_BYTES)
+ layout = SongLayout.of(song)
+ for phrase, offset in zip(song.planes.phrases.phrases, layout.bodies):
+ assert data[offset] == phrase.length
+ assert data[offset + 1 : offset + 1 + phrase.length] == phrase.body
+
+ def test_the_entries_stand_where_the_table_states(self) -> None:
+ song = spelled_phrase_song()
+ data = song_to_bytes(song, PROGRAM_AREA_BYTES)
+ table = read_word(data, PHRASE_TABLE_OFFSET)
+ stated = read_words(data, table + 1, len(song.planes.phrases))
+ assert stated == SongLayout.of(song).bodies
+
+
+def spelled_phrase_song() -> Song:
+ """A song whose figure repeats often enough for the dictionary to hold it."""
+ figure = (SOUNDING, OCTAVE_UP, RESTING, OCTAVE_UP)
+ return player_song(resting_streams(figure * 8), NTSC_FREQUENCY, loop_tick=None)
+
+
+class TestStreamOffsets:
+ """Every plane's stream is found where the header says it is."""
+
+ def test_the_first_stream_begins_past_the_dictionary(self) -> None:
+ song = two_tick_song(NTSC_FREQUENCY)
+ data = song_to_bytes(song, PROGRAM_AREA_BYTES)
+ assert stream_offsets(data)[0] == SongLayout.of(song).streams[0]
+
+ def test_the_offsets_ascend_in_plane_order(self) -> None:
+ data = song_to_bytes(two_tick_song(NTSC_FREQUENCY), PROGRAM_AREA_BYTES)
+ offsets = stream_offsets(data)
+ assert list(offsets) == sorted(offsets)
+
+ def test_each_offset_lands_on_that_planes_first_token(self) -> None:
+ song = two_tick_song(NTSC_FREQUENCY)
+ data = song_to_bytes(song, PROGRAM_AREA_BYTES)
+ for offset, stream in zip(stream_offsets(data), song.planes.streams):
+ assert data[offset : offset + len(stream)] == stream
+
+ def test_the_streams_fill_the_song_to_its_last_byte(self) -> None:
+ song = two_tick_song(NTSC_FREQUENCY)
+ data = song_to_bytes(song, PROGRAM_AREA_BYTES)
+ assert len(data) == stream_offsets(data)[-1] + len(song.planes.streams[-1])
+
+
+class TestLoopEntries:
+ """Where each plane's stream is re-entered once the song repeats."""
+
+ def test_a_song_that_repeats_states_the_token_its_loop_tick_starts(self) -> None:
+ song = repeating_song()
+ data = song_to_bytes(song, PROGRAM_AREA_BYTES)
+ entered = song.planes.entries(LOOP_TICK)
+ assert loop_entries(data) == tuple(offset + entry for offset, entry in zip(stream_offsets(data), entered))
+
+ def test_a_song_that_stops_re_enters_at_its_own_first_token(self) -> None:
+ data = song_to_bytes(two_tick_song(NTSC_FREQUENCY), PROGRAM_AREA_BYTES)
+ assert loop_entries(data) == stream_offsets(data)
+
+ def test_every_entry_lands_on_a_token_the_stream_holds(self) -> None:
+ song = repeating_song()
+ data = song_to_bytes(song, PROGRAM_AREA_BYTES)
+ for entry, offset, stream in zip(loop_entries(data), stream_offsets(data), song.planes.streams):
+ assert offset <= entry < offset + len(stream)
+
+
+class TestSongTooLarge:
+ """A song that outgrows what the header or the console can hold names the overflow."""
+
+ def test_a_song_past_the_available_space_raises(self) -> None:
+ song = two_tick_song(NTSC_FREQUENCY)
+ data = song_to_bytes(song, PROGRAM_AREA_BYTES)
+ with pytest.raises(SongTooLargeError):
+ song_to_bytes(song, len(data) - 1)
+
+ def test_a_song_filling_the_available_space_exactly_is_written(self) -> None:
+ song = two_tick_song(NTSC_FREQUENCY)
+ data = song_to_bytes(song, PROGRAM_AREA_BYTES)
+ assert song_to_bytes(song, len(data)) == data
+
+ def test_a_song_reaching_past_the_offset_field_names_what_overflowed(self) -> None:
+ """A block given exactly the room it takes is still refused where an offset overflows."""
+ song = spelled_song(MAX_BLOCK_OFFSET, NTSC_FREQUENCY)
+ with pytest.raises(SongTooLargeError) as overflow:
+ song_to_bytes(song, SongLayout.of(song).size)
+
+ named = [plane.replace(NAME_SEPARATOR, " ") for plane in PlaneOrder.names()]
+ assert any(plane in str(overflow.value) for plane in named)
diff --git a/tests/unit/sampletones_player/registers/__init__.py b/tests/unit/sampletones_player/registers/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/tests/unit/sampletones_player/registers/test_channel.py b/tests/unit/sampletones_player/registers/test_channel.py
new file mode 100644
index 000000000..c9b79b769
--- /dev/null
+++ b/tests/unit/sampletones_player/registers/test_channel.py
@@ -0,0 +1,89 @@
+from typing import Final, List
+
+import pytest
+
+from sampletones_core.constants.enums import ChannelName
+from sampletones_core.instructions import (
+ InstructionUnion,
+ NoiseInstruction,
+ PulseInstruction,
+ TriangleInstruction,
+)
+from sampletones_player.registers.channel import channel_instructions, channel_registers
+from sampletones_player.specification.registers import (
+ TRIANGLE_COUNTER_CONTROL,
+ TRIANGLE_SOUNDING_RELOAD,
+)
+from tests.suite.player import (
+ PLAYER_FULL_VOLUME,
+ PLAYER_REFERENCE_PITCH,
+ PLAYER_TIMER_TABLE,
+ sounding_pulse,
+)
+
+SOUNDING_TICKS: Final[int] = 4
+BASS_PITCH: Final[int] = 45
+NOISE_PERIOD: Final[int] = 10
+NOISE_VOLUME: Final[int] = 8
+
+
+def melody() -> List[InstructionUnion]:
+ return [sounding_pulse(PLAYER_REFERENCE_PITCH, PLAYER_FULL_VOLUME, 0) for _ in range(SOUNDING_TICKS)]
+
+
+class TestChannelInstructions:
+ """A channel's stream read as the instruction type its encoder takes."""
+
+ def test_a_stream_of_the_channels_own_type_passes_through(self) -> None:
+ instructions = melody()
+ assert channel_instructions(instructions, PulseInstruction) == instructions
+
+ def test_a_channel_describing_no_frame_rests_for_a_tick(self) -> None:
+ assert channel_instructions([], PulseInstruction) == [PulseInstruction.null_instruction()]
+
+ def test_a_resting_channel_rests_in_its_own_type(self) -> None:
+ assert channel_instructions([], NoiseInstruction) == [NoiseInstruction.null_instruction()]
+
+ def test_a_stream_of_another_channels_type_raises(self) -> None:
+ with pytest.raises(TypeError):
+ channel_instructions(melody(), TriangleInstruction)
+
+
+class TestChannelRegisters:
+ """Naming the channel is the whole of what it takes to encode a stream."""
+
+ def test_a_sounding_channel_carries_a_tick_per_instruction_and_a_release(self) -> None:
+ registers = channel_registers(ChannelName.PULSE1, {ChannelName.PULSE1: melody()}, PLAYER_TIMER_TABLE)
+ assert len(registers) == SOUNDING_TICKS + 1
+
+ def test_a_channel_the_song_leaves_out_rests_for_a_tick(self) -> None:
+ assert len(channel_registers(ChannelName.PULSE2, {}, PLAYER_TIMER_TABLE)) == 1
+
+ def test_a_pitch_reaches_the_timer_the_table_states(self) -> None:
+ registers = channel_registers(ChannelName.PULSE1, {ChannelName.PULSE1: melody()}, PLAYER_TIMER_TABLE)
+ timer = PLAYER_TIMER_TABLE[PLAYER_REFERENCE_PITCH]
+ assert registers[0].values[1:] == (timer & 0xFF, timer >> 8)
+
+ def test_the_triangle_channel_answers_in_its_own_registers(self) -> None:
+ instructions: List[InstructionUnion] = [TriangleInstruction(on=True, pitch=BASS_PITCH)]
+ registers = channel_registers(
+ ChannelName.TRIANGLE,
+ {ChannelName.TRIANGLE: instructions},
+ PLAYER_TIMER_TABLE,
+ )
+ assert registers[0].linear_counter == TRIANGLE_COUNTER_CONTROL | TRIANGLE_SOUNDING_RELOAD
+
+ def test_the_noise_channel_answers_in_its_own_registers(self) -> None:
+ instructions: List[InstructionUnion] = [
+ NoiseInstruction(on=True, period=NOISE_PERIOD, volume=NOISE_VOLUME, short=False),
+ ]
+ registers = channel_registers(
+ ChannelName.NOISE,
+ {ChannelName.NOISE: instructions},
+ PLAYER_TIMER_TABLE,
+ )
+ assert registers[0].control & 0x0F == NOISE_VOLUME
+
+ def test_a_channel_holding_another_channels_instructions_raises(self) -> None:
+ with pytest.raises(TypeError):
+ channel_registers(ChannelName.TRIANGLE, {ChannelName.TRIANGLE: melody()}, PLAYER_TIMER_TABLE)
diff --git a/tests/unit/sampletones_player/registers/test_noise.py b/tests/unit/sampletones_player/registers/test_noise.py
new file mode 100644
index 000000000..038e0397d
--- /dev/null
+++ b/tests/unit/sampletones_player/registers/test_noise.py
@@ -0,0 +1,56 @@
+from dataclasses import dataclass
+from typing import Tuple
+
+import pytest
+
+from sampletones_core.constants.general import MAX_PERIOD, MAX_VOLUME
+from sampletones_core.instructions import NoiseInstruction
+from sampletones_player.registers.noise import NoiseRegisters
+from tests.suite.base import BaseTestSuite
+from tests.suite.case import BaseAutolabelTestCase
+
+
+class TestNoiseRegisters(BaseTestSuite):
+ """The project counts noise periods from the slowest and the register from the fastest."""
+
+ @dataclass(frozen=True, kw_only=True)
+ class TestCase(BaseAutolabelTestCase):
+ expected: Tuple[int, int]
+ period: int
+ short: bool
+
+ @property
+ def label(self) -> str:
+ mode = "short" if self.short else "normal"
+ return f"period_{self.period}_{mode}"
+
+ test_cases = (
+ TestCase(period=0, short=False, expected=(0x3F, MAX_PERIOD)),
+ TestCase(period=MAX_PERIOD, short=False, expected=(0x3F, 0)),
+ TestCase(period=0, short=True, expected=(0x3F, 0x80 | MAX_PERIOD)),
+ TestCase(period=MAX_PERIOD, short=True, expected=(0x3F, 0x80)),
+ )
+
+ @pytest.mark.parametrize(
+ "test_case",
+ test_cases,
+ ids=lambda test_case: test_case.label,
+ )
+ def test_registers_match(self, test_case: TestCase) -> None:
+ instruction = NoiseInstruction(
+ on=True,
+ period=test_case.period,
+ volume=MAX_VOLUME,
+ short=test_case.short,
+ )
+ registers = NoiseRegisters.from_instructions([instruction])
+ assert registers[0].values == test_case.expected
+
+ def test_rest_clears_the_volume_and_keeps_the_period(self) -> None:
+ instructions = [
+ NoiseInstruction(on=True, period=4, volume=MAX_VOLUME, short=False),
+ NoiseInstruction.null_instruction(),
+ ]
+ sounding, resting = NoiseRegisters.from_instructions(instructions)[:2]
+ assert resting.control & 0x0F == 0
+ assert resting.period == sounding.period
diff --git a/tests/unit/sampletones_player/registers/test_pulse.py b/tests/unit/sampletones_player/registers/test_pulse.py
new file mode 100644
index 000000000..8cd0b3f69
--- /dev/null
+++ b/tests/unit/sampletones_player/registers/test_pulse.py
@@ -0,0 +1,136 @@
+from dataclasses import dataclass
+from typing import List
+
+import pytest
+
+from sampletones_core.constants.general import (
+ MAX_DUTY_CYCLE,
+ MAX_PITCH,
+ MAX_TIMER,
+ MAX_VOLUME,
+ MIN_PITCH,
+)
+from sampletones_player.registers.pulse import PulseRegisters
+from sampletones_player.specification.registers import MAX_REGISTER_VALUE, TIMER_HIGH_SHIFT
+from tests.suite.base import BaseTestSuite
+from tests.suite.case import BaseAutolabelTestCase
+from tests.suite.player import (
+ PLAYER_PULSE_TIMER_MUTE_FLOOR,
+ PLAYER_REFERENCE_PITCH,
+ PLAYER_TIMER_TABLE,
+ silent_pulse,
+ sounding_pulse,
+)
+
+
+class TestPulseTimerRange(BaseTestSuite):
+ """Every pitch a channel may sound reaches a timer the hardware plays.
+
+ The APU silences a pulse channel below timer 8 and the register holds 11 bits, so the
+ playable pitch range has to land between those two bounds for the driver to sound it.
+ """
+
+ @pytest.mark.parametrize("pitch", [MIN_PITCH, PLAYER_REFERENCE_PITCH, MAX_PITCH])
+ def test_timer_lies_within_the_audible_register_range(self, pitch: int) -> None:
+ timer = PLAYER_TIMER_TABLE[pitch]
+ assert PLAYER_PULSE_TIMER_MUTE_FLOOR <= timer <= MAX_TIMER
+
+ def test_timer_splits_into_a_byte_and_three_bits(self) -> None:
+ instructions = [sounding_pulse(PLAYER_REFERENCE_PITCH, MAX_VOLUME, 0)]
+ registers = PulseRegisters.from_instructions(instructions, PLAYER_TIMER_TABLE)
+ timer = PLAYER_TIMER_TABLE[PLAYER_REFERENCE_PITCH]
+ assert registers[0].timer_low == timer & MAX_REGISTER_VALUE
+ assert registers[0].timer_high == timer >> TIMER_HIGH_SHIFT
+
+
+class TestPulseTickRecord:
+ """A tick states its values in the order the driver moves them to its channel."""
+
+ def test_a_tick_states_control_then_timer(self) -> None:
+ instructions = [sounding_pulse(PLAYER_REFERENCE_PITCH, MAX_VOLUME, MAX_DUTY_CYCLE)]
+ registers = PulseRegisters.from_instructions(instructions, PLAYER_TIMER_TABLE)[0]
+ timer = PLAYER_TIMER_TABLE[PLAYER_REFERENCE_PITCH]
+ assert registers.values == (0xFF, timer & MAX_REGISTER_VALUE, timer >> TIMER_HIGH_SHIFT)
+
+
+class TestPulseControlByte(BaseTestSuite):
+ """The expected bytes are the values the APU reads from ``$4000``.
+
+ A duty cycle occupies the top two bits, the length-halt and constant-volume bits sit
+ below them, and the level fills the low nibble.
+ """
+
+ @dataclass(frozen=True, kw_only=True)
+ class TestCase(BaseAutolabelTestCase):
+ expected: int
+ duty_cycle: int
+ volume: int
+
+ @property
+ def label(self) -> str:
+ return f"duty_{self.duty_cycle}_volume_{self.volume}"
+
+ test_cases = (
+ TestCase(duty_cycle=0, volume=MAX_VOLUME, expected=0x3F),
+ TestCase(duty_cycle=1, volume=MAX_VOLUME, expected=0x7F),
+ TestCase(duty_cycle=2, volume=MAX_VOLUME, expected=0xBF),
+ TestCase(duty_cycle=MAX_DUTY_CYCLE, volume=MAX_VOLUME, expected=0xFF),
+ TestCase(duty_cycle=2, volume=0, expected=0xB0),
+ TestCase(duty_cycle=0, volume=1, expected=0x31),
+ )
+
+ @pytest.mark.parametrize(
+ "test_case",
+ test_cases,
+ ids=lambda test_case: test_case.label,
+ )
+ def test_control_byte_matches(self, test_case: TestCase) -> None:
+ instructions = [sounding_pulse(PLAYER_REFERENCE_PITCH, test_case.volume, test_case.duty_cycle)]
+ registers = PulseRegisters.from_instructions(instructions, PLAYER_TIMER_TABLE)
+ assert registers[0].control == test_case.expected
+
+
+class TestPulseRest:
+ """A rest zeroes the level and keeps everything else the channel was holding.
+
+ Holding the period across a rest is what lets the driver leave the timer's high byte
+ untouched, and leaving it untouched is what keeps the waveform's phase running.
+ """
+
+ @staticmethod
+ def encode_note_then_rest() -> List[PulseRegisters]:
+ instructions = [
+ sounding_pulse(PLAYER_REFERENCE_PITCH, MAX_VOLUME, MAX_DUTY_CYCLE),
+ silent_pulse(),
+ ]
+ return PulseRegisters.from_instructions(instructions, PLAYER_TIMER_TABLE)
+
+ def test_rest_clears_the_volume_nibble(self) -> None:
+ sounding, resting = self.encode_note_then_rest()[:2]
+ assert sounding.control & 0x0F == MAX_VOLUME
+ assert resting.control & 0x0F == 0
+
+ def test_rest_keeps_the_duty_cycle(self) -> None:
+ sounding, resting = self.encode_note_then_rest()[:2]
+ assert resting.control & 0xF0 == sounding.control & 0xF0
+
+ def test_rest_keeps_the_timer(self) -> None:
+ sounding, resting = self.encode_note_then_rest()[:2]
+ assert (resting.timer_low, resting.timer_high) == (sounding.timer_low, sounding.timer_high)
+
+
+class TestPulseReleaseTick:
+ """A sample that ends while sounding gains one closing tick that silences its channel."""
+
+ def test_pulse_gains_a_silent_closing_tick(self) -> None:
+ instructions = [sounding_pulse(PLAYER_REFERENCE_PITCH, MAX_VOLUME, 0)]
+ registers = PulseRegisters.from_instructions(instructions, PLAYER_TIMER_TABLE)
+ assert len(registers) == 2
+ assert registers[-1].control & 0x0F == 0
+
+ def test_a_sample_ending_in_a_rest_gains_no_extra_tick(self) -> None:
+ instructions = [sounding_pulse(PLAYER_REFERENCE_PITCH, MAX_VOLUME, 0), silent_pulse()]
+ assert len(PulseRegisters.from_instructions(instructions, PLAYER_TIMER_TABLE)) == 2
+
+ def test_no_instructions_encode_to_no_ticks(self) -> None:
+ assert PulseRegisters.from_instructions([], PLAYER_TIMER_TABLE) == []
diff --git a/tests/unit/sampletones_player/registers/test_streams.py b/tests/unit/sampletones_player/registers/test_streams.py
new file mode 100644
index 000000000..69a1cb7de
--- /dev/null
+++ b/tests/unit/sampletones_player/registers/test_streams.py
@@ -0,0 +1,69 @@
+from typing import Final
+
+import pytest
+from pydantic import ValidationError
+
+from tests.suite.player import (
+ PLAYER_FULL_VOLUME,
+ PLAYER_OCTAVE_UP_TIMER,
+ PLAYER_REFERENCE_PERIOD,
+ PLAYER_REFERENCE_TIMER,
+ PLAYER_SILENT_VOLUME,
+ noise_tick,
+ player_streams,
+ pulse_tick,
+ resting_streams,
+ triangle_tick,
+)
+
+SOUNDING: Final = pulse_tick(PLAYER_FULL_VOLUME, 0, PLAYER_REFERENCE_TIMER)
+RESTING: Final = pulse_tick(PLAYER_SILENT_VOLUME, 0, PLAYER_REFERENCE_TIMER)
+OCTAVE_UP: Final = pulse_tick(PLAYER_FULL_VOLUME, 0, PLAYER_OCTAVE_UP_TIMER)
+
+
+class TestChannelStreams:
+ """The four channels reach one common length, the longest of them stating it."""
+
+ def test_the_longest_channel_states_the_length(self) -> None:
+ streams = resting_streams((SOUNDING, OCTAVE_UP, RESTING))
+ assert streams.ticks == 3
+
+ def test_a_channel_past_its_end_holds_its_final_values(self) -> None:
+ streams = resting_streams((SOUNDING, RESTING))
+ _, pulse2, triangle, noise = streams.at(1)
+ assert (pulse2, triangle, noise) == (streams.pulse2[0], streams.triangle[0], streams.noise[0])
+
+ def test_every_channel_reads_its_own_tick_while_it_lasts(self) -> None:
+ streams = resting_streams((SOUNDING, OCTAVE_UP))
+ assert streams.at(0)[0] == SOUNDING
+ assert streams.at(1)[0] == OCTAVE_UP
+
+ def test_padding_carries_every_channel_to_the_songs_length(self) -> None:
+ streams = resting_streams((SOUNDING, OCTAVE_UP, RESTING))
+ assert tuple(len(stream) for stream in streams.padded) == (3, 3, 3, 3)
+
+ def test_padding_leaves_a_full_length_channel_as_it_stands(self) -> None:
+ streams = resting_streams((SOUNDING, OCTAVE_UP, RESTING))
+ assert streams.padded[0] == (SOUNDING, OCTAVE_UP, RESTING)
+
+ def test_padding_repeats_the_final_values_of_a_shorter_channel(self) -> None:
+ streams = resting_streams((SOUNDING, OCTAVE_UP, RESTING))
+ assert streams.padded[3] == (streams.noise[0],) * 3
+
+ def test_the_streams_stand_in_channel_order(self) -> None:
+ streams = resting_streams((SOUNDING,))
+ assert streams.ordered == (streams.pulse1, streams.pulse2, streams.triangle, streams.noise)
+
+ def test_a_channel_without_a_tick_raises(self) -> None:
+ with pytest.raises(ValidationError):
+ player_streams(
+ pulse1=(SOUNDING,),
+ pulse2=(),
+ triangle=(triangle_tick(False, PLAYER_REFERENCE_TIMER),),
+ noise=(noise_tick(PLAYER_SILENT_VOLUME, 0, PLAYER_REFERENCE_PERIOD),),
+ )
+
+ def test_the_streams_stay_as_built(self) -> None:
+ streams = resting_streams((SOUNDING,))
+ with pytest.raises(ValidationError):
+ streams.pulse1 = ()
diff --git a/tests/unit/sampletones_player/registers/test_triangle.py b/tests/unit/sampletones_player/registers/test_triangle.py
new file mode 100644
index 000000000..67101523e
--- /dev/null
+++ b/tests/unit/sampletones_player/registers/test_triangle.py
@@ -0,0 +1,49 @@
+from sampletones_core.constants.general import MAX_VOLUME
+from sampletones_core.instructions import TriangleInstruction
+from sampletones_player.registers.pulse import PulseRegisters
+from sampletones_player.registers.triangle import TriangleRegisters
+from sampletones_player.specification.registers import MAX_REGISTER_VALUE, TIMER_HIGH_SHIFT
+from tests.suite.player import PLAYER_REFERENCE_PITCH, PLAYER_TIMER_TABLE, sounding_pulse
+
+
+def sounding_triangle() -> TriangleInstruction:
+ return TriangleInstruction(on=True, pitch=PLAYER_REFERENCE_PITCH)
+
+
+class TestTriangleTickRecord:
+ """A tick states its values in the order the driver moves them to its channel."""
+
+ def test_a_tick_states_the_counter_then_timer(self) -> None:
+ registers = TriangleRegisters.from_instructions([sounding_triangle()], PLAYER_TIMER_TABLE)[0]
+ timer = PLAYER_TIMER_TABLE[PLAYER_REFERENCE_PITCH]
+ assert registers.values == (0xFF, timer & MAX_REGISTER_VALUE, timer >> TIMER_HIGH_SHIFT)
+
+
+class TestTriangleRegisters:
+ """The triangle states whether it sounds through the linear counter's reload value.
+
+ The control bit stays set so the counter reloads every frame, and a reload of zero is
+ what holds the channel silent.
+ """
+
+ def test_sounding_tick_reloads_the_counter_fully(self) -> None:
+ registers = TriangleRegisters.from_instructions([sounding_triangle()], PLAYER_TIMER_TABLE)
+ assert registers[0].linear_counter == 0xFF
+
+ def test_resting_tick_reloads_the_counter_to_zero(self) -> None:
+ instructions = [sounding_triangle(), TriangleInstruction.null_instruction()]
+ registers = TriangleRegisters.from_instructions(instructions, PLAYER_TIMER_TABLE)
+ assert registers[1].linear_counter == 0x80
+
+ def test_rest_keeps_the_timer(self) -> None:
+ instructions = [sounding_triangle(), TriangleInstruction.null_instruction()]
+ sounding, resting = TriangleRegisters.from_instructions(instructions, PLAYER_TIMER_TABLE)[:2]
+ assert (resting.timer_low, resting.timer_high) == (sounding.timer_low, sounding.timer_high)
+
+ def test_triangle_shares_the_pulse_timer(self) -> None:
+ triangle = TriangleRegisters.from_instructions([sounding_triangle()], PLAYER_TIMER_TABLE)
+ pulse = PulseRegisters.from_instructions(
+ [sounding_pulse(PLAYER_REFERENCE_PITCH, MAX_VOLUME, 0)],
+ PLAYER_TIMER_TABLE,
+ )
+ assert (triangle[0].timer_low, triangle[0].timer_high) == (pulse[0].timer_low, pulse[0].timer_high)
diff --git a/tests/unit/sampletones_player/test_builder.py b/tests/unit/sampletones_player/test_builder.py
new file mode 100644
index 000000000..bf04231de
--- /dev/null
+++ b/tests/unit/sampletones_player/test_builder.py
@@ -0,0 +1,294 @@
+from typing import Dict, Final, List
+
+import pytest
+
+from sampletones_core.constants.enums import ChannelName
+from sampletones_core.exports.request import InstrumentExport
+from sampletones_core.instructions import (
+ InstructionUnion,
+ NoiseInstruction,
+ PulseInstruction,
+ TriangleInstruction,
+)
+from sampletones_core.project.project import Project
+from sampletones_core.project.settings import ProjectSettings
+from sampletones_core.timers.utils import get_timer_table
+from sampletones_core.timing import SongTiming
+from sampletones_player.builder import (
+ SONG_START,
+ instructions_from_instruments,
+ loop_tick_from_instruments,
+ song_from_project,
+ song_from_reconstruction,
+ song_from_sample,
+ streams_from_instructions,
+)
+from sampletones_player.clock.schedule import PlaySchedule
+from sampletones_player.registers.channel import channel_registers
+from sampletones_player.specification.registers import (
+ TRIANGLE_COUNTER_CONTROL,
+ TRIANGLE_SOUNDING_RELOAD,
+)
+from sampletones_shared.music import Tuning
+from tests.suite.performance import (
+ make_pulse_reconstruction,
+ place_instrument,
+ project_with_sample,
+)
+from tests.suite.player import (
+ PLAYER_FULL_VOLUME,
+ PLAYER_REFERENCE_PITCH,
+ PLAYER_TIMER_TABLE,
+ player_features,
+ player_instrument,
+ player_reconstruction,
+ player_sample,
+ silent_pulse,
+ sounding_pulse,
+)
+
+NTSC_FREQUENCY: Final[int] = 60
+HALF_RATE_FREQUENCY: Final[int] = 30
+SOUNDING_TICKS: Final[int] = 4
+BASS_PITCH: Final[int] = 45
+NOISE_PERIOD: Final[int] = 10
+NOISE_VOLUME: Final[int] = 8
+RETUNED_A4_FREQUENCY: Final[float] = 432.0
+
+
+def melody() -> List[InstructionUnion]:
+ return [sounding_pulse(PLAYER_REFERENCE_PITCH, PLAYER_FULL_VOLUME, 0) for _ in range(SOUNDING_TICKS)]
+
+
+def one_channel(generator: ChannelName) -> Dict[ChannelName, List[InstructionUnion]]:
+ return {generator: melody()}
+
+
+def lead(*, loop: bool) -> InstrumentExport:
+ return player_instrument(
+ "lead",
+ ChannelName.PULSE1,
+ player_features(SOUNDING_TICKS, PLAYER_REFERENCE_PITCH, duty_cycle=True),
+ nes_frequency=NTSC_FREQUENCY,
+ loop=loop,
+ )
+
+
+def bass(*, loop: bool) -> InstrumentExport:
+ return player_instrument(
+ "bass",
+ ChannelName.TRIANGLE,
+ player_features(SOUNDING_TICKS, BASS_PITCH, duty_cycle=False),
+ nes_frequency=NTSC_FREQUENCY,
+ loop=loop,
+ )
+
+
+class TestStreamsFromInstructions:
+ """The four channels encoded together, each through the encoder its own type names."""
+
+ def test_a_sounding_channel_carries_a_tick_per_instruction_and_a_release(self) -> None:
+ streams = streams_from_instructions(one_channel(ChannelName.PULSE1), PLAYER_TIMER_TABLE)
+ assert len(streams.pulse1) == SOUNDING_TICKS + 1
+
+ def test_a_channel_describing_no_frame_carries_a_single_tick(self) -> None:
+ streams = streams_from_instructions(one_channel(ChannelName.PULSE1), PLAYER_TIMER_TABLE)
+ assert (len(streams.pulse2), len(streams.triangle), len(streams.noise)) == (1, 1, 1)
+
+ def test_a_pitch_reaches_the_timer_the_table_states(self) -> None:
+ streams = streams_from_instructions(one_channel(ChannelName.PULSE1), PLAYER_TIMER_TABLE)
+ timer = PLAYER_TIMER_TABLE[PLAYER_REFERENCE_PITCH]
+ assert (streams.pulse1[0].timer_low, streams.pulse1[0].timer_high) == (timer & 0xFF, timer >> 8)
+
+ def test_each_channel_reads_its_own_stream(self) -> None:
+ instructions: Dict[ChannelName, List[InstructionUnion]] = {
+ ChannelName.PULSE2: melody(),
+ ChannelName.TRIANGLE: [TriangleInstruction(on=True, pitch=BASS_PITCH)],
+ ChannelName.NOISE: [
+ NoiseInstruction(on=True, period=NOISE_PERIOD, volume=NOISE_VOLUME, short=False),
+ ],
+ }
+ streams = streams_from_instructions(instructions, PLAYER_TIMER_TABLE)
+ assert len(streams.pulse2) == SOUNDING_TICKS + 1
+ assert streams.triangle[0].linear_counter == TRIANGLE_COUNTER_CONTROL | TRIANGLE_SOUNDING_RELOAD
+ assert streams.noise[0].control & 0x0F == NOISE_VOLUME
+
+ def test_a_channel_holding_another_channels_instructions_raises(self) -> None:
+ instructions: Dict[ChannelName, List[InstructionUnion]] = {ChannelName.TRIANGLE: melody()}
+ with pytest.raises(TypeError):
+ streams_from_instructions(instructions, PLAYER_TIMER_TABLE)
+
+
+class TestSongFromReconstruction:
+ """A reconstruction read as the song the console plays it as."""
+
+ def test_the_song_lasts_the_ticks_its_longest_channel_covers(self) -> None:
+ reconstruction = player_reconstruction(one_channel(ChannelName.PULSE1), NTSC_FREQUENCY)
+ assert song_from_reconstruction(reconstruction, loop_tick=None).ticks == SOUNDING_TICKS + 1
+
+ def test_the_schedule_follows_the_rate_the_reconstruction_was_built_at(self) -> None:
+ reconstruction = player_reconstruction(one_channel(ChannelName.PULSE1), HALF_RATE_FREQUENCY)
+ song = song_from_reconstruction(reconstruction, loop_tick=None)
+ assert song.schedule == PlaySchedule.from_parameters(HALF_RATE_FREQUENCY)
+
+ def test_the_song_carries_the_loop_it_is_given(self) -> None:
+ reconstruction = player_reconstruction(one_channel(ChannelName.PULSE1), NTSC_FREQUENCY)
+ assert song_from_reconstruction(reconstruction, loop_tick=0).loop_tick == 0
+
+ def test_a_loop_beyond_the_songs_ticks_raises(self) -> None:
+ reconstruction = player_reconstruction(one_channel(ChannelName.PULSE1), NTSC_FREQUENCY)
+ with pytest.raises(ValueError):
+ song_from_reconstruction(reconstruction, loop_tick=SOUNDING_TICKS + 1)
+
+ def test_the_timers_come_from_the_reconstructions_own_configuration(self) -> None:
+ reconstruction = player_reconstruction(one_channel(ChannelName.PULSE1), NTSC_FREQUENCY)
+ song = song_from_reconstruction(reconstruction, loop_tick=None)
+ timer = get_timer_table(reconstruction.config.tuning)[PLAYER_REFERENCE_PITCH]
+ assert (song.streams.pulse1[0].timer_low, song.streams.pulse1[0].timer_high) == (timer & 0xFF, timer >> 8)
+
+ def test_a_retuned_reconstruction_plays_retuned_timers(self) -> None:
+ """The console reaches a pitch through a timer, so a reconstruction built against another
+ concert pitch plays the divider that concert pitch names.
+ """
+ reconstruction = player_reconstruction(one_channel(ChannelName.PULSE1), NTSC_FREQUENCY)
+ library = reconstruction.config.library.model_copy(update={"a4_frequency": RETUNED_A4_FREQUENCY})
+ retuned = reconstruction.model_copy(
+ update={"config": reconstruction.config.model_copy(update={"library": library})}
+ )
+ timer = get_timer_table(retuned.config.tuning)[PLAYER_REFERENCE_PITCH]
+ song = song_from_reconstruction(retuned, loop_tick=None)
+
+ assert timer > PLAYER_TIMER_TABLE[PLAYER_REFERENCE_PITCH]
+ assert (song.streams.pulse1[0].timer_low, song.streams.pulse1[0].timer_high) == (timer & 0xFF, timer >> 8)
+
+ def test_a_reconstruction_describing_no_frame_plays_one_resting_tick(self) -> None:
+ reconstruction = player_reconstruction({ChannelName.PULSE1: [silent_pulse()]}, NTSC_FREQUENCY)
+ song = song_from_reconstruction(reconstruction, loop_tick=None)
+ assert song.ticks == 1
+
+
+class TestInstructionsFromInstruments:
+ """An export request's channel slices read back as the instructions the console sounds."""
+
+ def test_a_slice_reaches_its_own_channel(self) -> None:
+ instructions = instructions_from_instruments((lead(loop=False), bass(loop=False)))
+ assert set(instructions) == {ChannelName.PULSE1, ChannelName.TRIANGLE}
+
+ def test_a_slice_reads_back_as_the_instruction_its_channel_sounds(self) -> None:
+ instructions = instructions_from_instruments((lead(loop=False), bass(loop=False)))
+ assert all(isinstance(item, PulseInstruction) for item in instructions[ChannelName.PULSE1])
+ assert all(isinstance(item, TriangleInstruction) for item in instructions[ChannelName.TRIANGLE])
+
+ def test_a_slice_carries_a_frame_per_envelope_item(self) -> None:
+ instructions = instructions_from_instruments((lead(loop=False),))
+ assert len(instructions[ChannelName.PULSE1]) == SOUNDING_TICKS
+
+ def test_two_slices_naming_one_channel_raise(self) -> None:
+ with pytest.raises(ValueError):
+ instructions_from_instruments((lead(loop=False), lead(loop=False)))
+
+
+class TestLoopTickFromInstruments:
+ """Where a request's song returns to once it ends."""
+
+ def test_slices_that_all_repeat_return_to_the_songs_start(self) -> None:
+ assert loop_tick_from_instruments((lead(loop=True), bass(loop=True))) == SONG_START
+
+ def test_a_slice_playing_once_ends_the_song(self) -> None:
+ assert loop_tick_from_instruments((lead(loop=True), bass(loop=False))) is None
+
+ def test_a_request_carrying_no_slice_ends_the_song(self) -> None:
+ assert loop_tick_from_instruments(()) is None
+
+
+class TestSongFromSample:
+ """An export request read as the song the console plays it as."""
+
+ def test_every_slice_sounds_on_the_channel_it_was_reconstructed_for(self) -> None:
+ song = song_from_sample(
+ player_sample("demo", (lead(loop=False), bass(loop=False)), nes_frequency=NTSC_FREQUENCY)
+ )
+ assert len(song.streams.pulse1) == SOUNDING_TICKS + 1
+ assert song.streams.triangle[0].linear_counter == TRIANGLE_COUNTER_CONTROL | TRIANGLE_SOUNDING_RELOAD
+ assert set(song.streams.pulse2) == {channel_registers(ChannelName.PULSE2, {}, PLAYER_TIMER_TABLE)[0]}
+
+ def test_the_schedule_follows_the_rate_the_request_states(self) -> None:
+ song = song_from_sample(player_sample("demo", (lead(loop=False),), nes_frequency=HALF_RATE_FREQUENCY))
+ assert song.schedule == PlaySchedule.from_parameters(HALF_RATE_FREQUENCY)
+
+ def test_a_request_whose_slices_repeat_loops(self) -> None:
+ song = song_from_sample(player_sample("demo", (lead(loop=True),), nes_frequency=NTSC_FREQUENCY))
+ assert song.loop_tick == SONG_START
+
+ def test_the_timers_come_from_the_tuning_the_request_carries(self) -> None:
+ song = song_from_sample(player_sample("demo", (lead(loop=False),), nes_frequency=NTSC_FREQUENCY))
+ timer = get_timer_table(Tuning())[PLAYER_REFERENCE_PITCH]
+ assert (song.streams.pulse1[0].timer_low, song.streams.pulse1[0].timer_high) == (timer & 0xFF, timer >> 8)
+
+ def test_a_retuned_request_plays_retuned_timers(self) -> None:
+ """The console reaches a pitch through a timer, so a request built against another concert
+ pitch plays the divider that concert pitch names.
+ """
+ tuning = Tuning(a4_frequency=RETUNED_A4_FREQUENCY)
+ song = song_from_sample(player_sample("demo", (lead(loop=False),), nes_frequency=NTSC_FREQUENCY, tuning=tuning))
+ timer = get_timer_table(tuning)[PLAYER_REFERENCE_PITCH]
+ assert (song.streams.pulse1[0].timer_low, song.streams.pulse1[0].timer_high) == (timer & 0xFF, timer >> 8)
+
+
+ROWS_PER_PATTERN: Final[int] = 4
+SONG_SETTINGS: Final[ProjectSettings] = ProjectSettings(
+ tempo=150,
+ speed=6,
+ nes_frequency=NTSC_FREQUENCY,
+)
+
+
+def drum_project() -> Project:
+ """A one-frame project sounding a pulse envelope from the first row."""
+ project, sample = project_with_sample(
+ make_pulse_reconstruction(pitch=PLAYER_REFERENCE_PITCH, count=SOUNDING_TICKS),
+ rows_per_pattern=ROWS_PER_PATTERN,
+ settings=SONG_SETTINGS,
+ )
+ place_instrument(
+ project,
+ channel_name=ChannelName.PULSE1,
+ row_index=0,
+ sample=sample,
+ )
+ return project
+
+
+class TestSongFromProject:
+ """A whole project reaching the console as the four streams the driver plays."""
+
+ def test_the_song_lasts_the_ticks_the_projects_groove_gives_its_rows(self) -> None:
+ project = drum_project()
+ song = song_from_project(project, None)
+ groove = SongTiming.from_project(project).groove()
+ assert song.ticks == project.song.order_length() * groove.total_ticks
+
+ def test_the_schedule_follows_the_rate_the_project_states(self) -> None:
+ song = song_from_project(drum_project(), None)
+ assert song.schedule == PlaySchedule.from_parameters(SONG_SETTINGS.nes_frequency)
+
+ def test_every_channel_carries_the_songs_whole_length(self) -> None:
+ song = song_from_project(drum_project(), None)
+ assert len(song.streams.noise) == song.ticks
+ assert len(song.streams.triangle) == song.ticks
+
+ def test_a_pitch_reaches_the_timer_the_tuning_names(self) -> None:
+ song = song_from_project(drum_project(), None)
+ timer = get_timer_table(Tuning())[PLAYER_REFERENCE_PITCH]
+ assert (song.streams.pulse1[0].timer_low, song.streams.pulse1[0].timer_high) == (timer & 0xFF, timer >> 8)
+
+ def test_the_song_carries_the_loop_it_is_given(self) -> None:
+ song = song_from_project(drum_project(), SONG_START)
+ assert song.loop_tick == SONG_START
+
+ def test_a_frame_the_order_plays_twice_lasts_twice_as_long(self) -> None:
+ project = drum_project()
+ one_frame = song_from_project(project, None).ticks
+ project.song.append_frame()
+ project.song.set_order_entry(1, ChannelName.PULSE1, 0)
+ assert song_from_project(project, None).ticks == 2 * one_frame
diff --git a/tests/unit/sampletones_player/test_export.py b/tests/unit/sampletones_player/test_export.py
new file mode 100644
index 000000000..3736dfb90
--- /dev/null
+++ b/tests/unit/sampletones_player/test_export.py
@@ -0,0 +1,414 @@
+import struct
+from pathlib import Path
+from typing import Final
+
+import pytest
+
+from sampletones_core.constants.enums import ChannelName
+from sampletones_core.exports.backend import ExportBackend
+from sampletones_core.exports.format import ExportFormat
+from sampletones_core.exports.progress import ExportProgress
+from sampletones_core.exports.request import (
+ InstrumentExport,
+ ProjectExport,
+ SampleExport,
+)
+from sampletones_core.exports.scope import ExportScope
+from sampletones_core.exports.stage import ExportStage
+from sampletones_core.project.project import Project
+from sampletones_core.project.settings import ProjectSettings
+from sampletones_core.timing import SongTiming
+from sampletones_player.builder import SONG_START, song_from_project
+from sampletones_player.driver.image import DriverImage
+from sampletones_player.export import NSFBackend
+from sampletones_player.specification.nsf import (
+ ARTIST_OFFSET,
+ HEADER_SIZE,
+ PROGRAM_SIZE,
+ STRING_FIELD_SIZE,
+ TITLE_OFFSET,
+)
+from sampletones_player.specification.song import LOOP_TICK_OFFSET
+from sampletones_shared.exceptions import OperationCanceled, SongTooLargeError
+from sampletones_shared.paths.extensions import EXT_FILE_NSF
+from tests.suite.performance import (
+ make_pulse_reconstruction,
+ place_instrument,
+ project_with_sample,
+)
+from tests.suite.player import (
+ PLAYER_REFERENCE_PITCH,
+ player_features,
+ player_instrument,
+ player_sample,
+ varied_features,
+)
+from tests.suite.progress import RecordingReporter, reported_stages
+
+NTSC_FREQUENCY: Final[int] = 60
+SOUNDING_TICKS: Final[int] = 8
+BASS_PITCH: Final[int] = 45
+OVERLONG_TICKS: Final[int] = 8192
+FILENAME: Final[str] = "reconstruction.nsf"
+SAMPLE_NAME: Final[str] = "Amen"
+PROJECT_TITLE: Final[str] = "Demo"
+PROJECT_AUTHOR: Final[str] = "Jakim"
+ROWS_PER_PATTERN: Final[int] = 4
+WITHDRAWN_WHILE_WALKING: Final[int] = 1
+WITHDRAWN_WHILE_COMPRESSING: Final[int] = 2
+
+
+def lead_slice(name: str, frames: int) -> InstrumentExport:
+ return player_instrument(
+ name,
+ ChannelName.PULSE1,
+ player_features(frames, PLAYER_REFERENCE_PITCH, duty_cycle=True),
+ nes_frequency=NTSC_FREQUENCY,
+ loop=False,
+ )
+
+
+def overlong_sample() -> SampleExport:
+ """A reconstruction whose channels turn over at every tick, so its song outgrows the console."""
+ return player_sample(
+ SAMPLE_NAME,
+ (
+ player_instrument(
+ "lead",
+ ChannelName.PULSE1,
+ varied_features(OVERLONG_TICKS, PLAYER_REFERENCE_PITCH, duty_cycle=True),
+ nes_frequency=NTSC_FREQUENCY,
+ loop=False,
+ ),
+ player_instrument(
+ "harmony",
+ ChannelName.PULSE2,
+ varied_features(OVERLONG_TICKS, PLAYER_REFERENCE_PITCH, duty_cycle=True),
+ nes_frequency=NTSC_FREQUENCY,
+ loop=False,
+ ),
+ player_instrument(
+ "bass",
+ ChannelName.TRIANGLE,
+ varied_features(OVERLONG_TICKS, BASS_PITCH, duty_cycle=False),
+ nes_frequency=NTSC_FREQUENCY,
+ loop=False,
+ ),
+ ),
+ nes_frequency=NTSC_FREQUENCY,
+ )
+
+
+def bass_slice(name: str, frames: int) -> InstrumentExport:
+ return player_instrument(
+ name,
+ ChannelName.TRIANGLE,
+ player_features(frames, BASS_PITCH, duty_cycle=False),
+ nes_frequency=NTSC_FREQUENCY,
+ loop=False,
+ )
+
+
+def written_loop_tick(data: bytes) -> int:
+ """The tick a written program comes round to, read out of the song block behind the driver."""
+ block = data[HEADER_SIZE + len(DriverImage.load().code) :]
+ return int(struct.unpack_from(" str:
+ return data[offset : offset + STRING_FIELD_SIZE].rstrip(b"\x00").decode()
+
+
+@pytest.fixture(name="backend")
+def backend_fixture() -> NSFBackend:
+ return NSFBackend()
+
+
+class TestSeam:
+ """What the backend answers the export seam with."""
+
+ def test_the_backend_writes_the_nsf_format(self, backend: NSFBackend) -> None:
+ assert backend.export_format == ExportFormat.NSF
+
+ def test_a_program_plays_every_scope_the_application_exports(self, backend: NSFBackend) -> None:
+ assert backend.supported_scopes == frozenset(ExportScope)
+
+ @pytest.mark.parametrize("scope", list(ExportScope))
+ def test_every_scope_the_backend_writes_carries_the_nsf_extension(
+ self,
+ backend: NSFBackend,
+ scope: ExportScope,
+ ) -> None:
+ assert backend.extension(scope) == EXT_FILE_NSF
+
+ def test_the_backend_stands_where_the_seam_expects_one(self, backend: NSFBackend) -> None:
+ export_backend: ExportBackend = backend
+ assert export_backend.export_format == ExportFormat.NSF
+
+
+class TestWriteSample:
+ """A reconstruction written as one program playing every slice together."""
+
+ def test_the_run_writes_the_destination_alone(self, backend: NSFBackend, tmp_path: Path) -> None:
+ destination = tmp_path / FILENAME
+ request = player_sample(
+ SAMPLE_NAME,
+ (lead_slice("lead", SOUNDING_TICKS), bass_slice("bass", SOUNDING_TICKS)),
+ nes_frequency=NTSC_FREQUENCY,
+ )
+ artifact = backend.write_sample(destination, request)
+ assert artifact.paths == (destination,)
+
+ def test_the_program_carries_its_driver(self, backend: NSFBackend, tmp_path: Path) -> None:
+ destination = tmp_path / FILENAME
+ request = player_sample(SAMPLE_NAME, (lead_slice("lead", SOUNDING_TICKS),), nes_frequency=NTSC_FREQUENCY)
+ backend.write_sample(destination, request)
+ assert len(destination.read_bytes()) > HEADER_SIZE
+
+ def test_the_reconstructions_name_lists_the_program(self, backend: NSFBackend, tmp_path: Path) -> None:
+ destination = tmp_path / FILENAME
+ request = player_sample(SAMPLE_NAME, (lead_slice("lead", SOUNDING_TICKS),), nes_frequency=NTSC_FREQUENCY)
+ backend.write_sample(destination, request)
+ assert read_field(destination.read_bytes(), TITLE_OFFSET) == SAMPLE_NAME
+
+ def test_an_export_is_credited_to_nobody(self, backend: NSFBackend, tmp_path: Path) -> None:
+ """A reconstruction names no artist, so the field reaches the file empty and a player
+ listing the file leaves the line blank.
+ """
+ destination = tmp_path / FILENAME
+ request = player_sample(SAMPLE_NAME, (lead_slice("lead", SOUNDING_TICKS),), nes_frequency=NTSC_FREQUENCY)
+ backend.write_sample(destination, request)
+ assert read_field(destination.read_bytes(), ARTIST_OFFSET) == ""
+
+ def test_the_envelopes_cross_over_whole(self, backend: NSFBackend, tmp_path: Path) -> None:
+ destination = tmp_path / FILENAME
+ request = player_sample(SAMPLE_NAME, (lead_slice("lead", SOUNDING_TICKS),), nes_frequency=NTSC_FREQUENCY)
+ assert backend.write_sample(destination, request).truncation is None
+
+ def test_a_destination_reaches_a_directory_the_run_creates(self, backend: NSFBackend, tmp_path: Path) -> None:
+ destination = tmp_path / "exports" / FILENAME
+ request = player_sample(SAMPLE_NAME, (lead_slice("lead", SOUNDING_TICKS),), nes_frequency=NTSC_FREQUENCY)
+ backend.write_sample(destination, request)
+ assert destination.is_file()
+
+ def test_a_reconstruction_outgrowing_the_program_area_reports_its_size(
+ self,
+ backend: NSFBackend,
+ tmp_path: Path,
+ ) -> None:
+ destination = tmp_path / FILENAME
+ with pytest.raises(SongTooLargeError):
+ backend.write_sample(destination, overlong_sample())
+
+
+class TestWriteInstrument:
+ """One channel slice written as a program sounding it alone."""
+
+ def test_the_slice_is_listed_under_its_own_name(self, backend: NSFBackend, tmp_path: Path) -> None:
+ destination = tmp_path / FILENAME
+ backend.write_instrument(destination, lead_slice("lead", SOUNDING_TICKS))
+ assert read_field(destination.read_bytes(), TITLE_OFFSET) == "lead"
+
+ def test_a_slice_plays_the_program_its_reconstruction_would(self, backend: NSFBackend, tmp_path: Path) -> None:
+ """A slice sounds on its own channel and the other three rest, which is the reconstruction
+ it belongs to with every other slice standing by.
+ """
+ instrument = lead_slice(SAMPLE_NAME, SOUNDING_TICKS)
+ alone = tmp_path / "alone.nsf"
+ together = tmp_path / "together.nsf"
+
+ backend.write_instrument(alone, instrument)
+ backend.write_sample(together, player_sample(SAMPLE_NAME, (instrument,), nes_frequency=NTSC_FREQUENCY))
+
+ assert alone.read_bytes() == together.read_bytes()
+
+
+def drum_project() -> Project:
+ """A one-frame project sounding a pulse envelope from its first row."""
+ project, sample = project_with_sample(
+ make_pulse_reconstruction(pitch=PLAYER_REFERENCE_PITCH, count=SOUNDING_TICKS),
+ rows_per_pattern=ROWS_PER_PATTERN,
+ settings=ProjectSettings(nes_frequency=NTSC_FREQUENCY),
+ )
+ project.info.title = PROJECT_TITLE
+ project.info.author = PROJECT_AUTHOR
+ place_instrument(
+ project,
+ channel_name=ChannelName.PULSE1,
+ row_index=0,
+ sample=sample,
+ )
+ return project
+
+
+class TestWriteProject:
+ """A whole composition written as one program the console plays."""
+
+ def test_the_file_is_written(self, backend: NSFBackend, tmp_path: Path) -> None:
+ destination = tmp_path / FILENAME
+ backend.write_project(destination, ProjectExport(project=drum_project()))
+ assert destination.is_file()
+
+ def test_the_program_is_listed_under_the_projects_own_title(
+ self,
+ backend: NSFBackend,
+ tmp_path: Path,
+ ) -> None:
+ destination = tmp_path / FILENAME
+ backend.write_project(destination, ProjectExport(project=drum_project()))
+ assert read_field(destination.read_bytes(), TITLE_OFFSET) == PROJECT_TITLE
+
+ def test_the_program_is_credited_to_the_projects_author(
+ self,
+ backend: NSFBackend,
+ tmp_path: Path,
+ ) -> None:
+ destination = tmp_path / FILENAME
+ backend.write_project(destination, ProjectExport(project=drum_project()))
+ assert read_field(destination.read_bytes(), ARTIST_OFFSET) == PROJECT_AUTHOR
+
+ def test_the_program_plays_the_song_the_project_arranges(
+ self,
+ backend: NSFBackend,
+ tmp_path: Path,
+ ) -> None:
+ """What reaches the file is the arrangement, so it lasts the ticks the groove gives it."""
+ project = drum_project()
+ destination = tmp_path / FILENAME
+ backend.write_project(destination, ProjectExport(project=project))
+ groove = SongTiming.from_project(project).groove()
+ expected = project.song.order_length() * groove.total_ticks
+ assert song_from_project(project, SONG_START).ticks == expected
+
+ def test_the_program_repeats_from_its_first_tick(
+ self,
+ backend: NSFBackend,
+ tmp_path: Path,
+ ) -> None:
+ """A piece of music is listened to over and over, so the file comes round where the
+ arrangement ends rather than falling silent there."""
+ destination = tmp_path / FILENAME
+ backend.write_project(destination, ProjectExport(project=drum_project()))
+ assert written_loop_tick(destination.read_bytes()) == SONG_START
+
+ def test_a_destination_reaches_a_directory_the_run_creates(
+ self,
+ backend: NSFBackend,
+ tmp_path: Path,
+ ) -> None:
+ destination = tmp_path / "exports" / FILENAME
+ backend.write_project(destination, ProjectExport(project=drum_project()))
+ assert destination.is_file()
+
+
+class TestWhatAProjectRunSaysAboutItself:
+ """A song is played out before it is compressed, and both stages read as they run."""
+
+ def test_the_run_names_each_stage_in_the_order_it_reaches_it(
+ self,
+ backend: NSFBackend,
+ tmp_path: Path,
+ ) -> None:
+ reporter: RecordingReporter[ExportProgress] = RecordingReporter()
+ backend.write_project(tmp_path / FILENAME, ProjectExport(project=drum_project()), reporter)
+ assert reported_stages(reporter.reports) == [
+ ExportStage.WALKING,
+ ExportStage.COMPRESSING,
+ ExportStage.WRITING,
+ ]
+
+ def test_the_walk_counts_the_ticks_the_song_lasts(
+ self,
+ backend: NSFBackend,
+ tmp_path: Path,
+ ) -> None:
+ """The groove states the length before a row is played, so the stage travels toward it."""
+ project = drum_project()
+ reporter: RecordingReporter[ExportProgress] = RecordingReporter()
+ backend.write_project(tmp_path / FILENAME, ProjectExport(project=project), reporter)
+
+ walked = [report for report in reporter.reports if report.stage == ExportStage.WALKING]
+ groove = SongTiming.from_project(project).groove()
+ expected = project.song.order_length() * groove.total_ticks
+ assert walked
+ assert [report.total for report in walked] == [expected] * len(walked)
+ assert walked[-1].completed == expected
+
+ def test_a_withdrawn_walk_leaves_no_file(
+ self,
+ backend: NSFBackend,
+ tmp_path: Path,
+ ) -> None:
+ destination = tmp_path / FILENAME
+ reporter: RecordingReporter[ExportProgress] = RecordingReporter(withdraw_at=WITHDRAWN_WHILE_WALKING)
+ with pytest.raises(OperationCanceled):
+ backend.write_project(destination, ProjectExport(project=drum_project()), reporter)
+
+ assert reporter.last.stage == ExportStage.WALKING
+ assert not destination.exists()
+
+
+class TestWhatARunSaysAboutItself:
+ """A program takes seconds to build, so the run names the work it is doing as it does it."""
+
+ def test_the_run_names_each_stage_in_the_order_it_reaches_it(
+ self,
+ backend: NSFBackend,
+ tmp_path: Path,
+ ) -> None:
+ reporter: RecordingReporter[ExportProgress] = RecordingReporter()
+ request = player_sample(SAMPLE_NAME, (lead_slice("lead", SOUNDING_TICKS),), nes_frequency=NTSC_FREQUENCY)
+ backend.write_sample(tmp_path / FILENAME, request, reporter)
+ assert reported_stages(reporter.reports) == [
+ ExportStage.WALKING,
+ ExportStage.COMPRESSING,
+ ExportStage.WRITING,
+ ]
+
+ def test_the_compression_reports_the_bytes_it_has_laid_down(
+ self,
+ backend: NSFBackend,
+ tmp_path: Path,
+ ) -> None:
+ """A search ends where the song runs out of phrases that pay, so it counts bytes alone."""
+ reporter: RecordingReporter[ExportProgress] = RecordingReporter()
+ request = player_sample(SAMPLE_NAME, (lead_slice("lead", SOUNDING_TICKS),), nes_frequency=NTSC_FREQUENCY)
+ backend.write_sample(tmp_path / FILENAME, request, reporter)
+ compressing = [report for report in reporter.reports if report.stage == ExportStage.COMPRESSING]
+ assert compressing and all(report.total is None for report in compressing)
+
+ def test_a_slice_reports_the_program_its_reconstruction_would(
+ self,
+ backend: NSFBackend,
+ tmp_path: Path,
+ ) -> None:
+ alone: RecordingReporter[ExportProgress] = RecordingReporter()
+ backend.write_instrument(tmp_path / FILENAME, lead_slice("lead", SOUNDING_TICKS), alone)
+ assert reported_stages(alone.reports)[0] == ExportStage.WALKING
+
+
+class TestWithdrawingARun:
+ """A caller that stops wanting the program is left with no file to open."""
+
+ def test_a_withdrawn_run_writes_nothing(self, backend: NSFBackend, tmp_path: Path) -> None:
+ destination = tmp_path / FILENAME
+ reporter: RecordingReporter[ExportProgress] = RecordingReporter(withdraw_at=WITHDRAWN_WHILE_WALKING)
+ request = player_sample(SAMPLE_NAME, (lead_slice("lead", SOUNDING_TICKS),), nes_frequency=NTSC_FREQUENCY)
+ with pytest.raises(OperationCanceled):
+ backend.write_sample(destination, request, reporter)
+
+ assert not destination.exists()
+
+ def test_a_run_withdrawn_mid_compression_writes_nothing(
+ self,
+ backend: NSFBackend,
+ tmp_path: Path,
+ ) -> None:
+ destination = tmp_path / FILENAME
+ reporter: RecordingReporter[ExportProgress] = RecordingReporter(withdraw_at=WITHDRAWN_WHILE_COMPRESSING)
+ request = player_sample(SAMPLE_NAME, (lead_slice("lead", SOUNDING_TICKS),), nes_frequency=NTSC_FREQUENCY)
+ with pytest.raises(OperationCanceled):
+ backend.write_sample(destination, request, reporter)
+
+ assert reporter.last.stage == ExportStage.COMPRESSING
+ assert not destination.exists()
diff --git a/tests/unit/sampletones_player/test_instrument_song.py b/tests/unit/sampletones_player/test_instrument_song.py
new file mode 100644
index 000000000..b3250fef4
--- /dev/null
+++ b/tests/unit/sampletones_player/test_instrument_song.py
@@ -0,0 +1,49 @@
+from typing import Final, Tuple
+
+from sampletones_core.constants.enums import ChannelName
+from sampletones_core.features.envelope import Envelope
+from sampletones_core.project.patterns.row import Row
+from sampletones_core.project.project import Project
+from sampletones_core.project.voices.envelopes import InstrumentEnvelopes
+from sampletones_core.project.voices.instrument import Instrument
+from sampletones_core.project.voices.note_on import NoteOn
+from sampletones_player.builder import song_from_project
+
+ROWS_PER_PATTERN: Final[int] = 4
+VOLUME: Final[Tuple[int, ...]] = (15, 12, 9)
+VOLUME_NIBBLE: Final[int] = 0x0F
+
+
+def _project() -> Project:
+ instrument = Instrument(
+ name="Lead",
+ envelopes=InstrumentEnvelopes(
+ volume=Envelope(items=VOLUME), arpeggio=Envelope(items=(0, 4, 7)), duty_cycle=Envelope(items=(1,))
+ ),
+ )
+ project = Project.create(title="Demo", rows_per_pattern=ROWS_PER_PATTERN)
+ project.voices.append(instrument)
+ pattern = project.song[ChannelName.PULSE1].ensure_pattern(0, ROWS_PER_PATTERN)
+ pattern.rows[0] = Row(command=NoteOn(voice_id=instrument.id))
+ project.song.set_order_entry(0, ChannelName.PULSE1, 0)
+ return project
+
+
+class TestAnInstrumentReachesTheConsole:
+ """The player reads the same walk the sequencer plays, so an instrument needs nothing of its own."""
+
+ def test_a_project_holding_an_instrument_compiles(self) -> None:
+ song = song_from_project(_project(), loop_tick=None)
+
+ assert song.planes.ticks > 0
+
+ def test_the_compiled_song_sounds_the_instrument_on_the_channel_it_was_placed_on(self) -> None:
+ song = song_from_project(_project(), loop_tick=None)
+
+ levels = [registers.control & VOLUME_NIBBLE for registers in song.streams.pulse1[: len(VOLUME)]]
+ assert levels == list(VOLUME)
+
+ def test_the_channels_it_was_not_placed_on_stay_silent(self) -> None:
+ song = song_from_project(_project(), loop_tick=None)
+
+ assert {registers.control & VOLUME_NIBBLE for registers in song.streams.pulse2} == {0}
diff --git a/tests/unit/sampletones_player/test_song.py b/tests/unit/sampletones_player/test_song.py
new file mode 100644
index 000000000..133dc586a
--- /dev/null
+++ b/tests/unit/sampletones_player/test_song.py
@@ -0,0 +1,97 @@
+from dataclasses import dataclass
+from typing import Final, Optional, Tuple
+
+import pytest
+from pydantic import ValidationError
+
+from sampletones_player.song import Song
+from tests.suite.base import BaseTestSuite
+from tests.suite.case import BaseAutolabelTestCase
+from tests.suite.player import (
+ PLAYER_FULL_VOLUME,
+ PLAYER_OCTAVE_UP_TIMER,
+ PLAYER_REFERENCE_TIMER,
+ PLAYER_SILENT_VOLUME,
+ player_song,
+ pulse_tick,
+ resting_streams,
+)
+
+NTSC_FREQUENCY: Final[int] = 60
+SOUNDING: Final = pulse_tick(PLAYER_FULL_VOLUME, 0, PLAYER_REFERENCE_TIMER)
+RESTING: Final = pulse_tick(PLAYER_SILENT_VOLUME, 0, PLAYER_REFERENCE_TIMER)
+OCTAVE_UP: Final = pulse_tick(PLAYER_FULL_VOLUME, 0, PLAYER_OCTAVE_UP_TIMER)
+
+
+class TestSongLoopBounds:
+ """A loop point names a tick the song actually plays."""
+
+ def test_a_loop_within_the_song_is_accepted(self) -> None:
+ song = player_song(resting_streams((SOUNDING, OCTAVE_UP)), NTSC_FREQUENCY, loop_tick=1)
+ assert song.loop_tick == 1
+
+ def test_a_song_may_stand_without_a_loop(self) -> None:
+ song = player_song(resting_streams((SOUNDING,)), NTSC_FREQUENCY, loop_tick=None)
+ assert song.loop_tick is None
+
+ def test_a_loop_at_the_songs_length_raises(self) -> None:
+ with pytest.raises(ValidationError, match="loop_tick must lie within"):
+ player_song(resting_streams((SOUNDING, OCTAVE_UP)), NTSC_FREQUENCY, loop_tick=2)
+
+ def test_a_negative_loop_raises(self) -> None:
+ with pytest.raises(ValidationError, match="loop_tick must lie within"):
+ player_song(resting_streams((SOUNDING,)), NTSC_FREQUENCY, loop_tick=-1)
+
+ def test_the_song_stays_as_built(self) -> None:
+ song = player_song(resting_streams((SOUNDING,)), NTSC_FREQUENCY, loop_tick=None)
+ with pytest.raises(ValidationError):
+ song.loop_tick = 0
+
+
+class TestSongPlayback(BaseTestSuite):
+ """The tick each call lands on, read across a song that ends and one that repeats."""
+
+ @dataclass(frozen=True, kw_only=True)
+ class TestCase(BaseAutolabelTestCase):
+ expected: Tuple[Optional[int], ...]
+ name: str
+ loop_tick: Optional[int]
+
+ @property
+ def label(self) -> str:
+ return self.name
+
+ @property
+ def song(self) -> Song:
+ return player_song(
+ resting_streams((SOUNDING, OCTAVE_UP, RESTING, RESTING)),
+ NTSC_FREQUENCY,
+ self.loop_tick,
+ )
+
+ test_cases: Tuple[TestCase, ...] = (
+ TestCase(name="stops", loop_tick=None, expected=(0, 1, 2, 3, None, None, None, None)),
+ TestCase(name="repeats-from-the-start", loop_tick=0, expected=(0, 1, 2, 3, 0, 1, 2, 3)),
+ TestCase(name="repeats-from-the-middle", loop_tick=2, expected=(0, 1, 2, 3, 2, 3, 2, 3)),
+ TestCase(name="repeats-one-tick", loop_tick=3, expected=(0, 1, 2, 3, 3, 3, 3, 3)),
+ )
+
+ @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label)
+ def test_the_calls_land_where_expected(self, test_case: TestCase) -> None:
+ song = test_case.song
+ ticks = tuple(song.tick_at(play_call) for play_call in range(len(test_case.expected)))
+ assert ticks == test_case.expected
+
+ @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label)
+ def test_every_tick_played_lies_within_the_song(self, test_case: TestCase) -> None:
+ song = test_case.song
+ played = [song.tick_at(play_call) for play_call in range(len(test_case.expected))]
+ assert all(0 <= tick < song.ticks for tick in played if tick is not None)
+
+ def test_the_song_lasts_as_long_as_its_streams(self) -> None:
+ song = player_song(resting_streams((SOUNDING, OCTAVE_UP)), NTSC_FREQUENCY, loop_tick=None)
+ assert song.ticks == song.streams.ticks
+
+ def test_a_slow_stream_holds_its_tick_between_calls(self) -> None:
+ song = player_song(resting_streams((SOUNDING, OCTAVE_UP, RESTING, RESTING)), 30, loop_tick=None)
+ assert tuple(song.tick_at(play_call) for play_call in range(6)) == (0, 0, 1, 1, 2, 2)
diff --git a/tests/unit/sampletones_player/trace/__init__.py b/tests/unit/sampletones_player/trace/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/tests/unit/sampletones_player/trace/test_trace.py b/tests/unit/sampletones_player/trace/test_trace.py
new file mode 100644
index 000000000..f800fd3bc
--- /dev/null
+++ b/tests/unit/sampletones_player/trace/test_trace.py
@@ -0,0 +1,199 @@
+from dataclasses import dataclass
+from typing import Final, Tuple
+
+import pytest
+
+from sampletones_player.specification.registers import (
+ APU_FRAME_COUNTER,
+ APU_STATUS,
+ CHANNELS_ENABLED,
+ FIRST_CHANNEL_REGISTER,
+ FRAME_COUNTER_SEQUENCE,
+ LAST_CHANNEL_REGISTER,
+ NOISE_LENGTH_COUNTER,
+ PULSE1_CONTROL,
+ PULSE1_SWEEP,
+ PULSE1_TIMER_HIGH,
+ PULSE1_TIMER_LOW,
+ PULSE2_SWEEP,
+ PULSE2_TIMER_HIGH,
+ REGISTERS_WRITTEN_ON_CHANGE,
+ SILENCED_REGISTER,
+ SWEEP_DISABLED,
+ TRIANGLE_TIMER_HIGH,
+)
+from sampletones_player.trace.trace import RegisterTrace
+from sampletones_player.trace.write import RegisterWrite
+from tests.suite.base import BaseTestSuite
+from tests.suite.case import BaseAutolabelTestCase
+from tests.suite.player import (
+ PLAYER_FULL_VOLUME,
+ PLAYER_OCTAVE_UP_TIMER,
+ PLAYER_REFERENCE_TIMER,
+ PLAYER_SILENT_VOLUME,
+ player_song,
+ pulse_tick,
+ resting_streams,
+)
+
+NTSC_FREQUENCY: Final[int] = 60
+HALF_RATE_FREQUENCY: Final[int] = 30
+WRITES_PER_TICK: Final[int] = 11
+
+SOUNDING: Final = pulse_tick(PLAYER_FULL_VOLUME, 0, PLAYER_REFERENCE_TIMER)
+RESTING: Final = pulse_tick(PLAYER_SILENT_VOLUME, 0, PLAYER_REFERENCE_TIMER)
+OCTAVE_UP: Final = pulse_tick(PLAYER_FULL_VOLUME, 0, PLAYER_OCTAVE_UP_TIMER)
+
+
+def addresses(writes: Tuple[RegisterWrite, ...]) -> Tuple[int, ...]:
+ return tuple(write.address for write in writes)
+
+
+class TestInitialization:
+ """The init routine leaves a silent console enabled and sounding the song's first tick."""
+
+ SONG: Final = player_song(resting_streams((SOUNDING, RESTING)), NTSC_FREQUENCY, loop_tick=None)
+
+ @property
+ def initialization(self) -> Tuple[RegisterWrite, ...]:
+ return RegisterTrace.from_song(self.SONG, play_calls=0).initialization
+
+ def test_every_channel_register_is_cleared_first(self) -> None:
+ cleared = self.initialization[: LAST_CHANNEL_REGISTER - FIRST_CHANNEL_REGISTER + 1]
+ assert addresses(cleared) == tuple(range(FIRST_CHANNEL_REGISTER, LAST_CHANNEL_REGISTER + 1))
+ assert all(write.value == SILENCED_REGISTER for write in cleared)
+
+ def test_the_channels_are_enabled(self) -> None:
+ assert RegisterWrite(APU_STATUS, CHANNELS_ENABLED) in self.initialization
+
+ def test_the_frame_counter_runs_without_an_interrupt(self) -> None:
+ assert RegisterWrite(APU_FRAME_COUNTER, FRAME_COUNTER_SEQUENCE) in self.initialization
+
+ def test_both_sweep_units_are_disabled(self) -> None:
+ assert RegisterWrite(PULSE1_SWEEP, SWEEP_DISABLED) in self.initialization
+ assert RegisterWrite(PULSE2_SWEEP, SWEEP_DISABLED) in self.initialization
+
+ def test_the_sweep_survives_the_clearing_pass(self) -> None:
+ sweeps = [write.value for write in self.initialization if write.address == PULSE1_SWEEP]
+ assert sweeps[-1] == SWEEP_DISABLED
+
+ def test_every_length_counter_loads_once_the_channels_are_enabled(self) -> None:
+ """A channel sounds only while its length counter stands above zero.
+
+ The counter loads from a write to the register carrying its length index, and only while
+ the channel is enabled, so every one of those registers has to be reached after
+ :data:`APU_STATUS`. The noise channel's is written for that alone; the other three carry
+ the first tick's timer high byte.
+ """
+ writes = self.initialization
+ enabled = writes.index(RegisterWrite(APU_STATUS, CHANNELS_ENABLED))
+ for address in (PULSE1_TIMER_HIGH, PULSE2_TIMER_HIGH, TRIANGLE_TIMER_HIGH, NOISE_LENGTH_COUNTER):
+ loaded = max(index for index, write in enumerate(writes) if write.address == address)
+ assert loaded > enabled
+
+ def test_the_first_tick_sounds_from_initialization(self) -> None:
+ first_tick = self.initialization[-WRITES_PER_TICK:]
+ assert len(first_tick) == WRITES_PER_TICK
+ assert first_tick[0] == RegisterWrite(PULSE1_CONTROL, self.SONG.streams.pulse1[0].control)
+
+ def test_the_first_tick_writes_the_registers_that_reset_a_channel(self) -> None:
+ first_tick = self.initialization[-WRITES_PER_TICK:]
+ assert REGISTERS_WRITTEN_ON_CHANGE.issubset(set(addresses(first_tick)))
+
+
+class TestChangeSuppression:
+ """The three registers that disturb a running channel are written only where they change."""
+
+ def test_a_held_pitch_leaves_the_timer_high_byte_alone(self) -> None:
+ song = player_song(resting_streams((SOUNDING, RESTING)), NTSC_FREQUENCY, loop_tick=None)
+ trace = RegisterTrace.from_song(song, play_calls=2)
+ assert PULSE1_TIMER_HIGH not in addresses(trace.play_calls[1])
+
+ def test_a_held_pitch_still_writes_the_timer_low_byte(self) -> None:
+ song = player_song(resting_streams((SOUNDING, RESTING)), NTSC_FREQUENCY, loop_tick=None)
+ trace = RegisterTrace.from_song(song, play_calls=2)
+ assert PULSE1_TIMER_LOW in addresses(trace.play_calls[1])
+
+ def test_a_pitch_change_writes_the_timer_high_byte(self) -> None:
+ song = player_song(resting_streams((SOUNDING, OCTAVE_UP)), NTSC_FREQUENCY, loop_tick=None)
+ trace = RegisterTrace.from_song(song, play_calls=2)
+ assert PULSE1_TIMER_HIGH in addresses(trace.play_calls[1])
+
+ def test_a_returning_pitch_writes_the_timer_high_byte_again(self) -> None:
+ song = player_song(resting_streams((SOUNDING, OCTAVE_UP, SOUNDING)), NTSC_FREQUENCY, loop_tick=None)
+ trace = RegisterTrace.from_song(song, play_calls=3)
+ assert PULSE1_TIMER_HIGH in addresses(trace.play_calls[2])
+
+ def test_an_unchanging_channel_never_rewrites_its_high_byte(self) -> None:
+ song = player_song(resting_streams((SOUNDING, RESTING, RESTING)), NTSC_FREQUENCY, loop_tick=None)
+ trace = RegisterTrace.from_song(song, play_calls=3)
+ rewritten = [write for writes in trace.play_calls for write in writes if write.address == TRIANGLE_TIMER_HIGH]
+ assert rewritten == []
+
+
+class TestPlaySchedule(BaseTestSuite):
+ """Which calls write and which leave the console alone, across the stream rates."""
+
+ @dataclass(frozen=True, kw_only=True)
+ class TestCase(BaseAutolabelTestCase):
+ expected: Tuple[bool, ...]
+ nes_frequency: int
+
+ @property
+ def label(self) -> str:
+ return f"{self.nes_frequency}hz"
+
+ test_cases: Tuple[TestCase, ...] = (
+ TestCase(nes_frequency=NTSC_FREQUENCY, expected=(False, True, True, True)),
+ TestCase(nes_frequency=HALF_RATE_FREQUENCY, expected=(False, False, True, False)),
+ TestCase(nes_frequency=15, expected=(False, False, False, False)),
+ )
+
+ @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label)
+ def test_only_the_calls_that_advance_write(self, test_case: TestCase) -> None:
+ song = player_song(
+ resting_streams((SOUNDING, OCTAVE_UP, RESTING, RESTING)),
+ test_case.nes_frequency,
+ loop_tick=None,
+ )
+ trace = RegisterTrace.from_song(song, play_calls=len(test_case.expected))
+ assert tuple(bool(writes) for writes in trace.play_calls) == test_case.expected
+
+ @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label)
+ def test_a_call_that_writes_touches_every_channel(self, test_case: TestCase) -> None:
+ song = player_song(
+ resting_streams((SOUNDING, OCTAVE_UP, RESTING, RESTING)),
+ test_case.nes_frequency,
+ loop_tick=None,
+ )
+ trace = RegisterTrace.from_song(song, play_calls=len(test_case.expected))
+ for writes in trace.play_calls:
+ assert not writes or PULSE1_CONTROL in addresses(writes)
+
+
+class TestEndOfSong:
+ """A song that ends stops writing, and one that repeats keeps playing from its loop tick."""
+
+ def test_a_song_without_a_loop_stops_writing(self) -> None:
+ song = player_song(resting_streams((SOUNDING, RESTING)), NTSC_FREQUENCY, loop_tick=None)
+ trace = RegisterTrace.from_song(song, play_calls=6)
+ assert all(writes == () for writes in trace.play_calls[3:])
+
+ def test_a_song_with_a_loop_keeps_writing(self) -> None:
+ song = player_song(resting_streams((SOUNDING, OCTAVE_UP)), NTSC_FREQUENCY, loop_tick=0)
+ trace = RegisterTrace.from_song(song, play_calls=6)
+ assert all(writes != () for writes in trace.play_calls[1:])
+
+ def test_a_loop_replays_the_ticks_it_returns_to(self) -> None:
+ song = player_song(resting_streams((SOUNDING, OCTAVE_UP)), NTSC_FREQUENCY, loop_tick=0)
+ trace = RegisterTrace.from_song(song, play_calls=4)
+ assert addresses(trace.play_calls[1]) == addresses(trace.play_calls[3])
+
+ def test_no_calls_produce_no_writes(self) -> None:
+ song = player_song(resting_streams((SOUNDING,)), NTSC_FREQUENCY, loop_tick=None)
+ assert RegisterTrace.from_song(song, play_calls=0).play_calls == ()
+
+ def test_a_negative_call_count_raises(self) -> None:
+ song = player_song(resting_streams((SOUNDING,)), NTSC_FREQUENCY, loop_tick=None)
+ with pytest.raises(ValueError):
+ RegisterTrace.from_song(song, play_calls=-1)
diff --git a/tests/unit/sampletones_shared/meta/import_boundary/__init__.py b/tests/unit/sampletones_shared/meta/import_boundary/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/tests/unit/sampletones_shared/meta/import_boundary/configs/__init__.py b/tests/unit/sampletones_shared/meta/import_boundary/configs/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/tests/unit/sampletones_shared/meta/import_boundary/configs/test_declaration.py b/tests/unit/sampletones_shared/meta/import_boundary/configs/test_declaration.py
new file mode 100644
index 000000000..de73889d2
--- /dev/null
+++ b/tests/unit/sampletones_shared/meta/import_boundary/configs/test_declaration.py
@@ -0,0 +1,61 @@
+from typing import Final
+
+from sampletones_shared.meta.import_boundary.configs.declaration import BoundaryDeclaration
+from sampletones_shared.meta.import_boundary.configs.general import GeneralBoundaries
+
+ROOT: Final[str] = "package"
+PATTERN: Final[str] = "logic/**/*.py"
+
+GENERAL: Final[GeneralBoundaries] = GeneralBoundaries(
+ groups={
+ "visual": ("dearpygui", "package.ui"),
+ "contracts": ("package.services.result",),
+ },
+)
+
+
+class TestDeclaredRule:
+ """What a declaration is written as, and the rule it amounts to."""
+
+ def test_a_declaration_naming_no_group_states_its_own_prefixes(self) -> None:
+ declaration = BoundaryDeclaration(
+ root=ROOT,
+ pattern=PATTERN,
+ forbidden=("package.services",),
+ )
+
+ assert declaration.rule(GENERAL).forbidden == ("package.services",)
+
+ def test_a_named_group_leads_the_prefixes_written_beside_it(self) -> None:
+ declaration = BoundaryDeclaration(
+ root=ROOT,
+ pattern=PATTERN,
+ forbidden_groups=("visual",),
+ forbidden=("package.services",),
+ )
+
+ assert declaration.rule(GENERAL).forbidden == (
+ "dearpygui",
+ "package.ui",
+ "package.services",
+ )
+
+ def test_a_group_reaches_the_contracts_the_same_way(self) -> None:
+ declaration = BoundaryDeclaration(
+ root=ROOT,
+ pattern=PATTERN,
+ forbidden=("package.services",),
+ contract_groups=("contracts",),
+ )
+
+ assert declaration.rule(GENERAL).contracts == ("package.services.result",)
+
+ def test_the_rule_is_written_against_the_tree_the_declaration_names(self) -> None:
+ declaration = BoundaryDeclaration(
+ root=ROOT,
+ pattern=PATTERN,
+ forbidden=("package.services",),
+ )
+ rule = declaration.rule(GENERAL)
+
+ assert (rule.root, rule.pattern) == (ROOT, PATTERN)
diff --git a/tests/unit/sampletones_shared/meta/import_boundary/configs/test_general.py b/tests/unit/sampletones_shared/meta/import_boundary/configs/test_general.py
new file mode 100644
index 000000000..51b85a3c6
--- /dev/null
+++ b/tests/unit/sampletones_shared/meta/import_boundary/configs/test_general.py
@@ -0,0 +1,33 @@
+from typing import Final
+
+import pytest
+
+from sampletones_shared.meta.import_boundary.configs.general import GeneralBoundaries
+
+GENERAL: Final[GeneralBoundaries] = GeneralBoundaries(
+ groups={
+ "visual": ("dearpygui", "package.ui"),
+ "contracts": ("package.services.result",),
+ },
+)
+
+
+class TestGroupPrefixes:
+ """A group gathers the prefixes several rules reach for, so the set is written once."""
+
+ def test_a_group_is_spelled_out_as_the_prefixes_it_gathers(self) -> None:
+ assert GENERAL.prefixes(("visual",)) == ("dearpygui", "package.ui")
+
+ def test_several_groups_follow_the_order_they_are_named_in(self) -> None:
+ assert GENERAL.prefixes(("contracts", "visual")) == (
+ "package.services.result",
+ "dearpygui",
+ "package.ui",
+ )
+
+ def test_naming_no_group_reaches_no_prefix(self) -> None:
+ assert GENERAL.prefixes(()) == ()
+
+ def test_a_name_reaching_no_group_is_reported(self) -> None:
+ with pytest.raises(KeyError):
+ GENERAL.prefixes(("absent",))
diff --git a/tests/unit/sampletones_shared/meta/import_boundary/configs/test_rules.py b/tests/unit/sampletones_shared/meta/import_boundary/configs/test_rules.py
new file mode 100644
index 000000000..58e9acf81
--- /dev/null
+++ b/tests/unit/sampletones_shared/meta/import_boundary/configs/test_rules.py
@@ -0,0 +1,164 @@
+from collections import Counter
+from pathlib import Path
+from typing import Dict, Final, List, Tuple
+
+import pytest
+from pydantic import ValidationError
+
+from sampletones_shared.meta.import_boundary.check import check_boundaries
+from sampletones_shared.meta.import_boundary.configs.declaration import BoundaryDeclaration
+from sampletones_shared.meta.import_boundary.configs.general import GeneralBoundaries
+from sampletones_shared.meta.import_boundary.configs.rules import ImportBoundaryRules
+from sampletones_shared.meta.import_boundary.graph import reached_units
+from sampletones_shared.meta.import_boundary.rule import BoundaryRule
+from sampletones_shared.meta.import_boundary.scope import rule_modules
+from sampletones_shared.paths.source import SOURCE_ROOT
+from tests.suite.source import swept_paths, write_module
+
+BOUNDARIES: Final[ImportBoundaryRules] = ImportBoundaryRules.load()
+
+APPLICATION: Final[str] = "sampletones_application"
+CORE: Final[str] = "sampletones_core"
+PLAYER: Final[str] = "sampletones_player"
+ASSEMBLER: Final[str] = "sampletones_player.driver.assembler"
+
+VISUAL_IMPORT: Final[str] = "import dearpygui.dearpygui as dpg\n"
+CONTRACT_IMPORT: Final[str] = "from sampletones_application.services.result import ServiceResult\n"
+PLAIN_IMPORT: Final[str] = "from sampletones_core.project.project import Project\n"
+PLAYER_IMPORT: Final[str] = "from sampletones_player.song import Song\n"
+ASSEMBLER_IMPORT: Final[str] = "from sampletones_player.driver.assembler.builder import build_driver\n"
+DRIVER_IMPORT: Final[str] = "from sampletones_player.driver.image import DriverImage\n"
+PANEL_SUFFIX: Final[str] = "def build() -> None:\n dpg.add_group(parent=SUF_PANEL_LEFT)\n"
+
+
+def reached_modules(rule: BoundaryRule) -> List[Path]:
+ """The modules a rule of the real source tree applies to."""
+ root = SOURCE_ROOT / rule.root
+ return rule_modules(root, rule.pattern, rule.excluding, swept_paths(root), None)
+
+
+def reported(tmp_path: Path) -> List[str]:
+ """What the shipped declaration reports over a tree a test builds."""
+ violations = check_boundaries(
+ tmp_path,
+ BOUNDARIES.boundary_rules(),
+ BOUNDARIES.tokens,
+ None,
+ )
+ return [violation.kind for violation in violations]
+
+
+class TestPackageGraph:
+ """The packages under the source root, and the order they may reach each other in."""
+
+ LAYERS: Final[Dict[str, Tuple[str, ...]]] = BOUNDARIES.graphs["packages"].layers
+
+ def test_every_package_of_the_source_tree_is_declared(self) -> None:
+ directories = {path.name for path in SOURCE_ROOT.iterdir() if (path / "__init__.py").is_file()}
+
+ assert set(self.LAYERS) == directories
+
+ def test_the_reconstruction_engine_stays_clear_of_the_console_player(self) -> None:
+ assert PLAYER not in reached_units(self.LAYERS, CORE)
+
+ def test_the_console_player_reads_the_reconstruction_engine(self) -> None:
+ assert CORE in self.LAYERS[PLAYER]
+
+ def test_the_synthesis_package_stands_below_the_reconstruction_engine(self) -> None:
+ """Equal temperament sits in `sampletones_shared`, so synthesis reaches no engine module."""
+ assert CORE not in reached_units(self.LAYERS, "sampletones_synthesis")
+
+
+class TestPlayerGraph:
+ """The player's own subpackages, and the order they may reach each other in."""
+
+ LAYERS: Final[Dict[str, Tuple[str, ...]]] = BOUNDARIES.graphs["player"].layers
+
+ def test_the_specification_is_the_layer_everything_stands_on(self) -> None:
+ assert self.LAYERS["specification"] == ()
+
+ def test_the_build_toolchain_is_reached_from_no_shipped_module(self) -> None:
+ """`driver/assembler/` stays outside the wheel, so an import of it breaks an installed copy."""
+ assert all("driver/assembler" not in layers for layers in self.LAYERS.values())
+
+ def test_the_driver_is_reached_through_the_file_that_writes_the_nsf(self) -> None:
+ assert "driver" in self.LAYERS["nsf"]
+
+ def test_every_module_of_the_player_belongs_to_one_unit(self) -> None:
+ rules = BOUNDARIES.graphs["player"].rules()
+ owners = Counter(path for rule in rules for path in reached_modules(rule))
+
+ assert set(owners) == swept_paths(SOURCE_ROOT / PLAYER)
+ assert set(owners.values()) == {1}
+
+
+class TestNamedGroups:
+ """A declaration names the groups it draws on, so a name reaching none is refused as it is read."""
+
+ def test_a_declaration_naming_no_declared_group_is_refused(self) -> None:
+ with pytest.raises(ValidationError):
+ ImportBoundaryRules(
+ general=GeneralBoundaries(groups={}),
+ graphs={},
+ rules=(
+ BoundaryDeclaration(
+ root=APPLICATION,
+ pattern="logic/**/*.py",
+ forbidden_groups=("absent",),
+ ),
+ ),
+ tokens=(),
+ )
+
+
+class TestDeclaredRules:
+ """Each declared boundary read over a tree that crosses it."""
+
+ def test_a_layer_reaching_the_interface_is_reported(self, tmp_path: Path) -> None:
+ write_module(tmp_path / APPLICATION / "logic", "direct.py", VISUAL_IMPORT)
+
+ assert reported(tmp_path) == ["dearpygui"]
+
+ def test_a_service_contract_stays_reachable(self, tmp_path: Path) -> None:
+ """A layer reads another layer's data contract while its implementation stays out of reach."""
+ write_module(tmp_path / APPLICATION / "logic", "direct.py", CONTRACT_IMPORT)
+
+ assert reported(tmp_path) == []
+
+ def test_the_reconstruction_engine_reaching_the_console_player_is_reported(self, tmp_path: Path) -> None:
+ write_module(tmp_path / CORE / "formats", "player.py", PLAYER_IMPORT)
+
+ assert reported(tmp_path) == [PLAYER]
+
+ def test_the_console_player_reading_the_engine_is_left_alone(self, tmp_path: Path) -> None:
+ write_module(tmp_path / PLAYER / "nsf", "file.py", PLAIN_IMPORT)
+
+ assert reported(tmp_path) == []
+
+ def test_a_shipped_module_reaching_the_build_toolchain_is_reported(self, tmp_path: Path) -> None:
+ write_module(tmp_path / PLAYER / "nsf", "file.py", ASSEMBLER_IMPORT)
+
+ assert reported(tmp_path) == [ASSEMBLER]
+
+ def test_the_build_toolchain_reads_the_driver_it_assembles(self, tmp_path: Path) -> None:
+ write_module(tmp_path / PLAYER / "driver" / "assembler", "builder.py", DRIVER_IMPORT)
+
+ assert reported(tmp_path) == []
+
+ def test_a_panel_composing_a_column_suffix_is_reported(self, tmp_path: Path) -> None:
+ write_module(tmp_path / APPLICATION / "ui" / "panels", "left.py", PANEL_SUFFIX)
+
+ assert len(reported(tmp_path)) == 1
+
+
+class TestRuleCoverage:
+ """A rule naming no module of the tree reads as a clean tree, so each one reaches something."""
+
+ def test_the_source_root_holds_modules(self) -> None:
+ assert swept_paths(SOURCE_ROOT)
+
+ def test_every_boundary_rule_reaches_a_module(self) -> None:
+ assert all(reached_modules(rule) for rule in BOUNDARIES.boundary_rules())
+
+ def test_every_token_rule_reaches_a_module(self) -> None:
+ assert all(list((SOURCE_ROOT / rule.root).glob(rule.pattern)) for rule in BOUNDARIES.tokens)
diff --git a/tests/unit/sampletones_shared/meta/import_boundary/test_check.py b/tests/unit/sampletones_shared/meta/import_boundary/test_check.py
new file mode 100644
index 000000000..7cd39473c
--- /dev/null
+++ b/tests/unit/sampletones_shared/meta/import_boundary/test_check.py
@@ -0,0 +1,100 @@
+from pathlib import Path
+from typing import Final, Tuple
+
+import pytest
+
+from sampletones_shared.meta.import_boundary.check import check_boundaries
+from sampletones_shared.meta.import_boundary.rule import BoundaryRule
+from sampletones_shared.meta.import_boundary.token import TokenRule
+from tests.suite.source import write_module
+
+FORBIDDEN: Final[str] = "from other_package.module import Thing\n"
+ALLOWED: Final[str] = "from package.inner import Helper\n"
+SPELLING: Final[str] = "dpg.add_group(parent=SUF_PANEL_LEFT)\n"
+
+RULES: Final[Tuple[BoundaryRule, ...]] = (
+ BoundaryRule(
+ root="package",
+ pattern="logic/**/*.py",
+ forbidden=("other_package",),
+ ),
+ BoundaryRule(
+ root="package",
+ pattern="nested/**/*.py",
+ forbidden=("other_package",),
+ excluding=("nested/inner/**/*.py",),
+ ),
+)
+
+TOKEN_RULES: Final[Tuple[TokenRule, ...]] = (
+ TokenRule(
+ root="package",
+ pattern="ui/**/*.py",
+ forbidden=r"\bSUF_PANEL_",
+ message="ui stays clear of a column suffix",
+ ),
+)
+
+
+class TestCheckBoundaries:
+ """Every rule read over one tree, from the sweep to the report."""
+
+ def test_a_forbidden_import_is_reported(self, tmp_path: Path) -> None:
+ write_module(tmp_path / "package" / "logic", "direct.py", FORBIDDEN)
+
+ violations = check_boundaries(tmp_path, RULES, TOKEN_RULES, None)
+
+ assert [violation.kind for violation in violations] == ["other_package"]
+
+ def test_an_allowed_import_reports_nothing(self, tmp_path: Path) -> None:
+ write_module(tmp_path / "package" / "logic", "direct.py", ALLOWED)
+
+ assert check_boundaries(tmp_path, RULES, TOKEN_RULES, None) == []
+
+ def test_a_module_outside_every_rule_stays_unchecked(self, tmp_path: Path) -> None:
+ write_module(tmp_path / "package" / "services", "conversion.py", FORBIDDEN)
+
+ assert check_boundaries(tmp_path, RULES, TOKEN_RULES, None) == []
+
+ def test_a_module_a_nested_rule_owns_is_left_to_it(self, tmp_path: Path) -> None:
+ write_module(tmp_path / "package" / "nested" / "inner", "deep.py", FORBIDDEN)
+
+ assert check_boundaries(tmp_path, RULES, TOKEN_RULES, None) == []
+
+ def test_a_forbidden_spelling_is_reported(self, tmp_path: Path) -> None:
+ write_module(tmp_path / "package" / "ui", "left.py", SPELLING)
+
+ violations = check_boundaries(tmp_path, RULES, TOKEN_RULES, None)
+
+ assert [violation.kind for violation in violations] == ["ui stays clear of a column suffix"]
+
+ def test_the_boundary_rules_are_reported_before_the_token_rules(self, tmp_path: Path) -> None:
+ write_module(tmp_path / "package" / "ui", "left.py", SPELLING)
+ write_module(tmp_path / "package" / "logic", "direct.py", FORBIDDEN)
+
+ violations = check_boundaries(tmp_path, RULES, TOKEN_RULES, None)
+
+ assert [violation.kind for violation in violations] == [
+ "other_package",
+ "ui stays clear of a column suffix",
+ ]
+
+ def test_a_selection_narrows_the_check(self, tmp_path: Path) -> None:
+ checked = write_module(tmp_path / "package" / "logic", "checked.py", FORBIDDEN)
+ write_module(tmp_path / "package" / "logic", "other.py", FORBIDDEN)
+
+ violations = check_boundaries(tmp_path, RULES, TOKEN_RULES, {checked.resolve()})
+
+ assert len(violations) == 1
+
+
+class TestSweptRoots:
+ """A root the sweep reads nothing under stops the check, where it would report a clean tree."""
+
+ def test_a_root_holding_no_module_stops_the_check(self, tmp_path: Path) -> None:
+ with pytest.raises(FileNotFoundError):
+ check_boundaries(tmp_path, RULES, TOKEN_RULES, None)
+
+ def test_an_absent_root_stops_the_check(self, tmp_path: Path) -> None:
+ with pytest.raises(NotADirectoryError):
+ check_boundaries(tmp_path / "absent", RULES, TOKEN_RULES, None)
diff --git a/tests/unit/sampletones_shared/meta/import_boundary/test_graph.py b/tests/unit/sampletones_shared/meta/import_boundary/test_graph.py
new file mode 100644
index 000000000..d6857ef97
--- /dev/null
+++ b/tests/unit/sampletones_shared/meta/import_boundary/test_graph.py
@@ -0,0 +1,78 @@
+from typing import Final
+
+import pytest
+from pydantic import ValidationError
+
+from sampletones_shared.meta.import_boundary.graph import LayerGraph, reached_units
+
+GRAPH: Final[LayerGraph] = LayerGraph(
+ root="package",
+ package="package",
+ layers={"low": (), "high": ("low",), "high/nested": ("low",)},
+)
+
+
+class TestLayerRules:
+ """A graph states what a unit may import, and the rule the check runs is what remains."""
+
+ def test_one_rule_stands_for_each_declared_unit(self) -> None:
+ assert len(GRAPH.rules()) == len(GRAPH.layers)
+
+ def test_a_unit_forbids_the_units_its_layers_leave_out(self) -> None:
+ low, _, _ = GRAPH.rules()
+ assert low.forbidden == ("package.high", "package.high.nested")
+
+ def test_a_unit_stays_free_of_the_units_it_may_import(self) -> None:
+ _, high, _ = GRAPH.rules()
+ assert "package.low" not in high.forbidden
+
+ def test_a_unit_never_forbids_itself(self) -> None:
+ assert all(rule.pattern.split("/")[0] not in rule.forbidden for rule in GRAPH.rules())
+
+ def test_a_nested_unit_is_left_out_of_the_unit_around_it(self) -> None:
+ _, high, _ = GRAPH.rules()
+ assert high.excluding == ("high/nested/**/*.py",)
+
+ def test_every_rule_is_written_against_the_graphs_root(self) -> None:
+ assert all(rule.root == "package" for rule in GRAPH.rules())
+
+ def test_a_rule_names_its_unit_by_the_glob_the_unit_holds(self) -> None:
+ assert [rule.pattern for rule in GRAPH.rules()] == ["low/**/*.py", "high/**/*.py", "high/nested/**/*.py"]
+
+
+class TestWellFormedGraphs:
+ """A graph states an order over its units, so one it cannot state is refused as it is read."""
+
+ def test_a_unit_reaching_an_undeclared_unit_is_refused(self) -> None:
+ with pytest.raises(ValidationError):
+ LayerGraph(
+ root="package",
+ package="package",
+ layers={"high": ("absent",)},
+ )
+
+ def test_a_graph_closing_a_cycle_is_refused(self) -> None:
+ with pytest.raises(ValidationError):
+ LayerGraph(
+ root="package",
+ package="package",
+ layers={"low": ("high",), "high": ("low",)},
+ )
+
+ def test_a_unit_importing_itself_is_refused(self) -> None:
+ with pytest.raises(ValidationError):
+ LayerGraph(
+ root="package",
+ package="package",
+ layers={"low": ("low",)},
+ )
+
+
+class TestReachedUnits:
+ """What a unit imports through the units it imports, which is what an order is read from."""
+
+ def test_a_unit_reaches_what_its_layers_reach(self) -> None:
+ assert reached_units(GRAPH.layers, "high") == {"low"}
+
+ def test_a_unit_standing_on_nothing_reaches_nothing(self) -> None:
+ assert reached_units(GRAPH.layers, "low") == set()
diff --git a/tests/unit/sampletones_shared/meta/import_boundary/test_imports.py b/tests/unit/sampletones_shared/meta/import_boundary/test_imports.py
new file mode 100644
index 000000000..681e2c086
--- /dev/null
+++ b/tests/unit/sampletones_shared/meta/import_boundary/test_imports.py
@@ -0,0 +1,53 @@
+from dataclasses import dataclass
+from typing import Optional, Tuple
+
+import pytest
+
+from sampletones_shared.meta.import_boundary.imports import imported_module, matches_prefix
+from tests.suite.base import BaseTestSuite
+from tests.suite.case import BaseAutolabelTestCase
+
+
+class TestImportedModule(BaseTestSuite):
+ """The module one line of source imports, whichever spelling the line uses."""
+
+ @dataclass(frozen=True, kw_only=True)
+ class TestCase(BaseAutolabelTestCase):
+ expected: Optional[str]
+ line: str
+ name: str
+
+ @property
+ def label(self) -> str:
+ return self.name
+
+ test_cases: Tuple[TestCase, ...] = (
+ TestCase(name="plain-import", line="import numpy", expected="numpy"),
+ TestCase(name="aliased-import", line="import numpy as np", expected="numpy"),
+ TestCase(name="dotted-import", line="import dearpygui.dearpygui as dpg", expected="dearpygui.dearpygui"),
+ TestCase(name="from-import", line="from package.module import Thing", expected="package.module"),
+ TestCase(name="indented-import", line=" import numpy", expected="numpy"),
+ TestCase(name="assignment", line="imported = 1", expected=None),
+ TestCase(name="comment", line="# import numpy", expected=None),
+ TestCase(name="blank", line="", expected=None),
+ )
+
+ @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label)
+ def test_the_line_names_the_module_it_imports(self, test_case: TestCase) -> None:
+ assert imported_module(test_case.line) == test_case.expected
+
+
+class TestMatchesPrefix:
+ """A prefix names the module itself and everything underneath it, and nothing beside it."""
+
+ def test_the_prefix_itself_matches(self) -> None:
+ assert matches_prefix("package.module", "package.module")
+
+ def test_a_module_underneath_matches(self) -> None:
+ assert matches_prefix("package.module.inner", "package.module")
+
+ def test_a_module_beside_it_stays_clear(self) -> None:
+ assert not matches_prefix("package.modules", "package.module")
+
+ def test_a_module_above_it_stays_clear(self) -> None:
+ assert not matches_prefix("package", "package.module")
diff --git a/tests/unit/sampletones_shared/meta/import_boundary/test_rule.py b/tests/unit/sampletones_shared/meta/import_boundary/test_rule.py
new file mode 100644
index 000000000..a6058a404
--- /dev/null
+++ b/tests/unit/sampletones_shared/meta/import_boundary/test_rule.py
@@ -0,0 +1,63 @@
+from pathlib import Path
+from typing import Final
+
+from sampletones_shared.meta.import_boundary.rule import BoundaryRule
+from tests.suite.source import write_module
+
+FORBIDDEN: Final[str] = "from other_package.module import Thing\n"
+CONTRACT: Final[str] = "from other_package.contract import Result\n"
+ALLOWED: Final[str] = "from package.inner import Helper\n"
+
+RULE: Final[BoundaryRule] = BoundaryRule(
+ root="package",
+ pattern="logic/**/*.py",
+ forbidden=("other_package", "third_package"),
+ contracts=("other_package.contract",),
+)
+
+
+class TestBoundaryViolations:
+ """The imports one module takes past the boundary around it."""
+
+ def test_a_forbidden_import_is_named_by_the_prefix_it_crosses(self, tmp_path: Path) -> None:
+ path = write_module(tmp_path, "direct.py", FORBIDDEN)
+
+ assert [violation.kind for violation in RULE.violations(path)] == ["other_package"]
+
+ def test_the_report_names_the_line_the_import_sits_on(self, tmp_path: Path) -> None:
+ path = write_module(tmp_path, "direct.py", f"{ALLOWED}{FORBIDDEN}")
+
+ assert RULE.violations(path)[0].location == f"{path}:2: {FORBIDDEN.strip()}"
+
+ def test_a_contract_module_stays_reachable(self, tmp_path: Path) -> None:
+ """A layer reads another layer's data contract while its implementation stays out of reach."""
+ path = write_module(tmp_path, "direct.py", CONTRACT)
+
+ assert RULE.violations(path) == []
+
+ def test_an_allowed_import_reports_nothing(self, tmp_path: Path) -> None:
+ path = write_module(tmp_path, "direct.py", ALLOWED)
+
+ assert RULE.violations(path) == []
+
+ def test_a_module_importing_nothing_reports_nothing(self, tmp_path: Path) -> None:
+ path = write_module(tmp_path, "direct.py", "VALUE = 1\n")
+
+ assert RULE.violations(path) == []
+
+ def test_one_import_is_reported_once(self, tmp_path: Path) -> None:
+ """An import crosses one boundary, so the first prefix it matches names it."""
+ rule = BoundaryRule(
+ root=RULE.root,
+ pattern=RULE.pattern,
+ forbidden=("other_package", "other_package.module"),
+ )
+ path = write_module(tmp_path, "direct.py", FORBIDDEN)
+
+ assert len(rule.violations(path)) == 1
+
+ def test_every_forbidden_import_is_reported_in_line_order(self, tmp_path: Path) -> None:
+ body = f"{FORBIDDEN}{ALLOWED}from third_package.module import Other\n"
+ path = write_module(tmp_path, "direct.py", body)
+
+ assert [violation.kind for violation in RULE.violations(path)] == ["other_package", "third_package"]
diff --git a/tests/unit/sampletones_shared/meta/import_boundary/test_scope.py b/tests/unit/sampletones_shared/meta/import_boundary/test_scope.py
new file mode 100644
index 000000000..92050e731
--- /dev/null
+++ b/tests/unit/sampletones_shared/meta/import_boundary/test_scope.py
@@ -0,0 +1,52 @@
+from pathlib import Path
+from typing import Final
+
+from sampletones_shared.meta.import_boundary.scope import rule_modules
+from tests.suite.source import swept_paths, write_module
+
+LOGIC_PATTERN: Final[str] = "logic/**/*.py"
+BODY: Final[str] = "from package.inner import Helper\n"
+
+
+class TestRuleModules:
+ """The modules one glob reaches, held to the tree the sweep reads."""
+
+ def test_a_module_directly_under_the_rule_directory_is_reached(self, tmp_path: Path) -> None:
+ """`logic/**/*.py` names `logic/direct.py` as surely as `logic/inner/deep.py`."""
+ direct = write_module(tmp_path / "logic", "direct.py", BODY)
+
+ assert rule_modules(tmp_path, LOGIC_PATTERN, (), swept_paths(tmp_path), None) == [direct.resolve()]
+
+ def test_a_module_nested_under_the_rule_directory_is_reached(self, tmp_path: Path) -> None:
+ deep = write_module(tmp_path / "logic" / "inner", "deep.py", BODY)
+
+ assert rule_modules(tmp_path, LOGIC_PATTERN, (), swept_paths(tmp_path), None) == [deep.resolve()]
+
+ def test_a_module_outside_the_rule_directory_stays_aside(self, tmp_path: Path) -> None:
+ write_module(tmp_path / "services", "conversion.py", BODY)
+
+ assert rule_modules(tmp_path, LOGIC_PATTERN, (), swept_paths(tmp_path), None) == []
+
+ def test_a_module_a_nested_rule_owns_is_left_to_it(self, tmp_path: Path) -> None:
+ direct = write_module(tmp_path / "logic", "direct.py", BODY)
+ write_module(tmp_path / "logic" / "inner", "deep.py", BODY)
+
+ reached = rule_modules(tmp_path, LOGIC_PATTERN, ("logic/inner/**/*.py",), swept_paths(tmp_path), None)
+
+ assert reached == [direct.resolve()]
+
+ def test_a_selection_narrows_the_rule_to_the_files_it_names(self, tmp_path: Path) -> None:
+ named = write_module(tmp_path / "logic", "named.py", BODY)
+ write_module(tmp_path / "logic", "other.py", BODY)
+
+ reached = rule_modules(tmp_path, LOGIC_PATTERN, (), swept_paths(tmp_path), {named.resolve()})
+
+ assert reached == [named.resolve()]
+
+ def test_the_modules_are_reported_in_path_order(self, tmp_path: Path) -> None:
+ second = write_module(tmp_path / "logic", "second.py", BODY)
+ first = write_module(tmp_path / "logic", "first.py", BODY)
+
+ reached = rule_modules(tmp_path, LOGIC_PATTERN, (), swept_paths(tmp_path), None)
+
+ assert reached == [first.resolve(), second.resolve()]
diff --git a/tests/unit/sampletones_shared/meta/import_boundary/test_token.py b/tests/unit/sampletones_shared/meta/import_boundary/test_token.py
new file mode 100644
index 000000000..d1b3018a9
--- /dev/null
+++ b/tests/unit/sampletones_shared/meta/import_boundary/test_token.py
@@ -0,0 +1,56 @@
+from pathlib import Path
+from typing import Final
+
+import pytest
+from pydantic import ValidationError
+
+from sampletones_shared.meta.import_boundary.token import TokenRule
+from tests.suite.source import write_module
+
+MESSAGE: Final[str] = "a panel receives its parent through create_panel(parent)"
+
+RULE: Final[TokenRule] = TokenRule(
+ root="package",
+ pattern="ui/**/*.py",
+ forbidden=r"\bSUF_PANEL_",
+ message=MESSAGE,
+)
+
+
+class TestTokenViolations:
+ """The lines of one module that write a spelling the rule keeps out."""
+
+ def test_the_forbidden_spelling_is_reported_with_the_rules_message(self, tmp_path: Path) -> None:
+ path = write_module(tmp_path, "left.py", "dpg.add_group(parent=SUF_PANEL_LEFT)\n")
+
+ assert [violation.kind for violation in RULE.violations(path)] == [MESSAGE]
+
+ def test_the_report_names_the_line_the_spelling_sits_on(self, tmp_path: Path) -> None:
+ line = "dpg.add_group(parent=SUF_PANEL_LEFT)"
+ path = write_module(tmp_path, "left.py", f"VALUE = 1\n{line}\n")
+
+ assert RULE.violations(path)[0].location == f"{path}:2: {line}"
+
+ def test_a_module_clear_of_the_spelling_reports_nothing(self, tmp_path: Path) -> None:
+ path = write_module(tmp_path, "left.py", "dpg.add_group(parent=parent)\n")
+
+ assert RULE.violations(path) == []
+
+ def test_every_line_writing_the_spelling_is_reported(self, tmp_path: Path) -> None:
+ body = "first = SUF_PANEL_LEFT\nsecond = 1\nthird = SUF_PANEL_RIGHT\n"
+ path = write_module(tmp_path, "left.py", body)
+
+ assert len(RULE.violations(path)) == 2
+
+
+class TestUnusableSpellings:
+ """A rule states a spelling `re` accepts, so one it cannot read is refused as the rule is."""
+
+ def test_a_spelling_that_is_no_expression_is_refused(self) -> None:
+ with pytest.raises(ValidationError):
+ TokenRule(
+ root="package",
+ pattern="ui/**/*.py",
+ forbidden="(unclosed",
+ message=MESSAGE,
+ )
diff --git a/tests/unit/sampletones_shared/meta/import_boundary/test_units.py b/tests/unit/sampletones_shared/meta/import_boundary/test_units.py
new file mode 100644
index 000000000..d824a4adf
--- /dev/null
+++ b/tests/unit/sampletones_shared/meta/import_boundary/test_units.py
@@ -0,0 +1,32 @@
+from sampletones_shared.meta.import_boundary.units import nested_globs, unit_glob, unit_prefix
+
+PLAYER = "sampletones_player"
+
+
+class TestUnitGlobs:
+ """A unit names either one module or a directory of them, and reads as both."""
+
+ def test_a_directory_unit_reaches_every_module_below_it(self) -> None:
+ assert unit_glob("driver") == "driver/**/*.py"
+
+ def test_a_module_unit_names_itself(self) -> None:
+ assert unit_glob("song.py") == "song.py"
+
+ def test_a_nested_unit_is_named_as_the_glob_it_owns(self) -> None:
+ assert nested_globs("driver", ("driver", "driver/assembler", "clock")) == ("driver/assembler/**/*.py",)
+
+ def test_a_unit_beside_another_is_left_to_itself(self) -> None:
+ assert nested_globs("clock", ("driver", "driver/assembler", "clock")) == ()
+
+
+class TestUnitPrefixes:
+ """The dotted prefix an import of a unit begins with."""
+
+ def test_a_unit_under_a_package_is_reached_by_a_dotted_prefix(self) -> None:
+ assert unit_prefix(PLAYER, "driver/assembler") == "sampletones_player.driver.assembler"
+
+ def test_a_module_unit_drops_its_suffix(self) -> None:
+ assert unit_prefix(PLAYER, "song.py") == "sampletones_player.song"
+
+ def test_a_package_unit_is_reached_by_its_own_name(self) -> None:
+ assert unit_prefix("", "sampletones_core") == "sampletones_core"
diff --git a/tests/unit/sampletones_shared/meta/import_boundary/test_violation.py b/tests/unit/sampletones_shared/meta/import_boundary/test_violation.py
new file mode 100644
index 000000000..af8e8a0f5
--- /dev/null
+++ b/tests/unit/sampletones_shared/meta/import_boundary/test_violation.py
@@ -0,0 +1,21 @@
+from pathlib import Path
+
+from sampletones_shared.meta.import_boundary.violation import Violation
+
+
+class TestViolationLocation:
+ """Where a report sends a reader to see what a rule caught."""
+
+ def test_the_location_reads_as_path_and_line(self) -> None:
+ """The location states the module, the line number and the line, in the platform's own separator."""
+ violation = Violation.at("other_package", Path("package/logic/direct.py"), 2, "import other_package")
+
+ assert violation.location.replace("\\", "/") == "package/logic/direct.py:2: import other_package"
+
+ def test_the_quoted_line_stands_clear_of_its_indentation(self) -> None:
+ violation = Violation.at("other_package", Path("direct.py"), 1, " import other_package")
+
+ assert violation.location.endswith(": import other_package")
+
+ def test_the_kind_names_what_the_rule_forbids(self) -> None:
+ assert Violation.at("other_package", Path("direct.py"), 1, "import other_package").kind == "other_package"
diff --git a/tests/unit/sampletones_shared/meta/source/bindings/test_containers.py b/tests/unit/sampletones_shared/meta/source/bindings/test_containers.py
index 2145ce5b8..b66c9d09e 100644
--- a/tests/unit/sampletones_shared/meta/source/bindings/test_containers.py
+++ b/tests/unit/sampletones_shared/meta/source/bindings/test_containers.py
@@ -8,12 +8,12 @@
)
from tests.suite.source import parse_source
-FILTER_TYPES: Final[Tuple[str, ...]] = ("TrackerFormat", "FileFilterElements")
+FILTER_TYPES: Final[Tuple[str, ...]] = ("ExportFormat", "FileFilterElements")
ANNOTATED_SOURCE: Final[str] = """
from typing import Dict, Final, List
-FILTERS: Final[Dict[TrackerFormat, FileFilterElements]] = {}
+FILTERS: Final[Dict[ExportFormat, FileFilterElements]] = {}
NAMES: Final[List[str]] = []
PLAIN = {}
@@ -71,8 +71,8 @@ def test_a_computed_iterable_reads_no_container(self) -> None:
class TestIteratedTypes:
def test_walking_items_types_the_key_and_the_value_target(self) -> None:
- assert typed_names("tracker_format, element", "items", FILTER_TYPES) == [
- ("tracker_format", "TrackerFormat"),
+ assert typed_names("export_format, element", "items", FILTER_TYPES) == [
+ ("export_format", "ExportFormat"),
("element", "FileFilterElements"),
]
@@ -88,20 +88,20 @@ def test_walking_values_types_the_target_from_the_value_type(self) -> None:
]
def test_walking_keys_types_the_target_from_the_key_type(self) -> None:
- assert typed_names("tracker_format", "keys", FILTER_TYPES) == [
+ assert typed_names("export_format", "keys", FILTER_TYPES) == [
(
- "tracker_format",
- "TrackerFormat",
+ "export_format",
+ "ExportFormat",
)
]
def test_walking_a_container_directly_types_the_target_from_the_key_type(
self,
) -> None:
- assert typed_names("tracker_format", None, FILTER_TYPES) == [
+ assert typed_names("export_format", None, FILTER_TYPES) == [
(
- "tracker_format",
- "TrackerFormat",
+ "export_format",
+ "ExportFormat",
)
]
diff --git a/tests/unit/sampletones_shared/meta/source/bindings/test_scopes.py b/tests/unit/sampletones_shared/meta/source/bindings/test_scopes.py
index 22ee5cc57..aa4b6d2aa 100644
--- a/tests/unit/sampletones_shared/meta/source/bindings/test_scopes.py
+++ b/tests/unit/sampletones_shared/meta/source/bindings/test_scopes.py
@@ -7,7 +7,7 @@
PANEL_SOURCE: Final[str] = """
from typing import Dict, Final
-FILTERS: Final[Dict[TrackerFormat, FileFilterElements]] = {}
+FILTERS: Final[Dict[ExportFormat, FileFilterElements]] = {}
class Panel:
@@ -29,8 +29,8 @@ def label(element: SequencerTrackerElements) -> str:
self._labels = [label(item) for item in FILTERS.values()]
def _filters(self) -> None:
- for tracker_format, element in FILTERS.items():
- print(tracker_format, element)
+ for export_format, element in FILTERS.items():
+ print(export_format, element)
def _names(self) -> None:
for name in FILTERS:
@@ -39,23 +39,23 @@ def _names(self) -> None:
IMPORTED_SOURCE: Final[str] = """
def create() -> None:
- for tracker_format, element in TRACKER_FILTERS.items():
- print(tracker_format, element)
+ for export_format, element in EXPORT_FILTERS.items():
+ print(export_format, element)
"""
LOCAL_OVER_IMPORTED_SOURCE: Final[str] = """
from typing import Dict, Final
-TRACKER_FILTERS: Final[Dict[str, MenuElements]] = {}
+EXPORT_FILTERS: Final[Dict[str, MenuElements]] = {}
def create() -> None:
- for element in TRACKER_FILTERS.values():
+ for element in EXPORT_FILTERS.values():
print(element)
"""
IMPORTED_FILTERS: Final[Mapping[str, Tuple[str, ...]]] = {
- "TRACKER_FILTERS": ("TrackerFormat", "FileFilterElements"),
+ "EXPORT_FILTERS": ("ExportFormat", "FileFilterElements"),
}
@@ -137,10 +137,10 @@ class TestLoopTargets:
def test_walking_items_states_the_key_and_the_value_type(self) -> None:
environment = panel_environment("_filters")
assert (
- environment.type_of("tracker_format"),
+ environment.type_of("export_format"),
environment.type_of("element"),
) == (
- "TrackerFormat",
+ "ExportFormat",
"FileFilterElements",
)
@@ -148,7 +148,7 @@ def test_walking_values_states_the_value_type(self) -> None:
assert panel_environment("_load").type_of("item") == "FileFilterElements"
def test_walking_a_mapping_states_the_key_type(self) -> None:
- assert panel_environment("_names").type_of("name") == "TrackerFormat"
+ assert panel_environment("_names").type_of("name") == "ExportFormat"
def test_an_imported_container_states_its_item_types(self) -> None:
assert (
diff --git a/tests/unit/sampletones_shared/meta/source/test_annotations.py b/tests/unit/sampletones_shared/meta/source/test_annotations.py
index e08917df6..36b3de5a7 100644
--- a/tests/unit/sampletones_shared/meta/source/test_annotations.py
+++ b/tests/unit/sampletones_shared/meta/source/test_annotations.py
@@ -118,13 +118,13 @@ class TestCase(BaseRegularTestCase):
test_cases = (
TestCase(
label="mapping_states_key_then_value",
- annotation="Dict[TrackerFormat, FileFilterElements]",
- expected=("TrackerFormat", "FileFilterElements"),
+ annotation="Dict[ExportFormat, FileFilterElements]",
+ expected=("ExportFormat", "FileFilterElements"),
),
TestCase(
label="wrapped_mapping",
- annotation="Final[Dict[TrackerFormat, FileFilterElements]]",
- expected=("TrackerFormat", "FileFilterElements"),
+ annotation="Final[Dict[ExportFormat, FileFilterElements]]",
+ expected=("ExportFormat", "FileFilterElements"),
),
TestCase(
label="homogeneous_tuple",
diff --git a/tests/unit/sampletones_shared/meta/source/test_index.py b/tests/unit/sampletones_shared/meta/source/test_index.py
index a2da5abb0..7e50e97c0 100644
--- a/tests/unit/sampletones_shared/meta/source/test_index.py
+++ b/tests/unit/sampletones_shared/meta/source/test_index.py
@@ -9,7 +9,7 @@
TAGS_SOURCE: Final[str] = """
from typing import Dict, Final
-FILTERS: Final[Dict[TrackerFormat, FileFilterElements]] = {}
+FILTERS: Final[Dict[ExportFormat, FileFilterElements]] = {}
TAG_MAIN_WINDOW = "main.window"
"""
@@ -32,7 +32,7 @@ def index_of(*sources: str) -> SourceIndex:
class TestSourceIndex:
def test_a_container_states_its_item_types(self) -> None:
assert index_of(TAGS_SOURCE).item_types["FILTERS"] == (
- "TrackerFormat",
+ "ExportFormat",
"FileFilterElements",
)
diff --git a/tests/unit/sampletones_shared/test_music.py b/tests/unit/sampletones_shared/test_music.py
new file mode 100644
index 000000000..535aa5b5a
--- /dev/null
+++ b/tests/unit/sampletones_shared/test_music.py
@@ -0,0 +1,58 @@
+import pytest
+from pydantic import ValidationError
+
+from sampletones_shared.constants.music import (
+ A4_FREQUENCY,
+ A4_PITCH,
+ LIMIT_MAX_PITCH,
+ LIMIT_MIN_PITCH,
+ MAX_A4_FREQUENCY,
+ MIN_A4_FREQUENCY,
+ OCTAVE_SEMITONES,
+)
+from sampletones_shared.music import Tuning
+
+
+class TestDefaults:
+ def test_the_default_is_standard_concert_pitch(self) -> None:
+ tuning = Tuning()
+ assert (tuning.a4_frequency, tuning.a4_pitch) == (A4_FREQUENCY, A4_PITCH)
+
+
+class TestFrequency:
+ def test_the_reference_pitch_sounds_the_reference_frequency(self) -> None:
+ assert Tuning().frequency(A4_PITCH) == A4_FREQUENCY
+
+ def test_an_octave_below_halves_the_frequency(self) -> None:
+ assert Tuning().frequency(A4_PITCH - OCTAVE_SEMITONES) == A4_FREQUENCY / 2
+
+ def test_a_retuned_reference_carries_the_whole_scale(self) -> None:
+ tuning = Tuning(a4_frequency=432.0)
+ assert tuning.frequency(A4_PITCH) == 432.0
+ assert tuning.frequency(A4_PITCH - OCTAVE_SEMITONES) == 216.0
+
+ def test_moving_the_reference_pitch_moves_every_frequency(self) -> None:
+ tuning = Tuning(a4_pitch=A4_PITCH - OCTAVE_SEMITONES)
+ assert tuning.frequency(A4_PITCH - OCTAVE_SEMITONES) == A4_FREQUENCY
+ assert tuning.frequency(A4_PITCH) == A4_FREQUENCY * 2
+
+ @pytest.mark.parametrize("pitch", [LIMIT_MIN_PITCH - 1, LIMIT_MAX_PITCH + 1])
+ def test_a_pitch_beyond_the_projects_range_raises(self, pitch: int) -> None:
+ with pytest.raises(ValueError):
+ Tuning().frequency(pitch)
+
+
+class TestValidation:
+ @pytest.mark.parametrize("a4_frequency", [0.0, -440.0, MIN_A4_FREQUENCY, MAX_A4_FREQUENCY, 880.0])
+ def test_a_reference_frequency_outside_the_tuning_band_raises(self, a4_frequency: float) -> None:
+ with pytest.raises(ValidationError):
+ Tuning(a4_frequency=a4_frequency)
+
+ @pytest.mark.parametrize("a4_pitch", [LIMIT_MIN_PITCH - 1, LIMIT_MAX_PITCH + 1])
+ def test_a_reference_pitch_beyond_the_projects_range_raises(self, a4_pitch: int) -> None:
+ with pytest.raises(ValidationError):
+ Tuning(a4_pitch=a4_pitch)
+
+ def test_the_tuning_holds_still(self) -> None:
+ with pytest.raises(ValidationError):
+ Tuning().a4_frequency = 432.0
diff --git a/tests/unit/sampletones_shared/utils/system/reveal/test_file_manager1.py b/tests/unit/sampletones_shared/utils/system/reveal/test_file_manager1.py
new file mode 100644
index 000000000..b5c80665d
--- /dev/null
+++ b/tests/unit/sampletones_shared/utils/system/reveal/test_file_manager1.py
@@ -0,0 +1,74 @@
+from pathlib import Path
+from unittest.mock import MagicMock, patch
+
+from jeepney import (
+ AuthenticationError,
+ DBusErrorResponse,
+ Endianness,
+ HeaderFields,
+ MessageFlag,
+ MessageType,
+)
+from jeepney.low_level import Header, Message
+
+from sampletones_shared.utils.system.reveal.file_manager1 import (
+ EMPTY_STARTUP_ID,
+ FILE_MANAGER_ADDRESS,
+ SHOW_ITEMS_METHOD,
+ SHOW_ITEMS_SIGNATURE,
+ FileManager1Backend,
+)
+
+MODULE = "sampletones_shared.utils.system.reveal.file_manager1"
+METHOD_UNKNOWN_ERROR: str = "org.freedesktop.FileManager1.MethodUnknown"
+
+
+def _error_response() -> DBusErrorResponse:
+ header = Header(
+ Endianness.little,
+ MessageType.error,
+ MessageFlag.no_reply_expected,
+ 1,
+ 0,
+ 0,
+ {HeaderFields.error_name: METHOD_UNKNOWN_ERROR},
+ )
+ return DBusErrorResponse(Message(header, ()))
+
+
+class TestOpen:
+ def test_sends_one_show_items_call_with_every_uri(self) -> None:
+ paths = (
+ Path("/a/one.wav"),
+ Path("/b/two.wav"),
+ )
+
+ with patch(f"{MODULE}.open_dbus_connection") as open_connection:
+ connection = MagicMock()
+ open_connection.return_value.__enter__.return_value = connection
+ with patch(f"{MODULE}.new_method_call") as build_call:
+ FileManager1Backend().open(paths)
+
+ build_call.assert_called_once_with(
+ FILE_MANAGER_ADDRESS,
+ SHOW_ITEMS_METHOD,
+ SHOW_ITEMS_SIGNATURE,
+ ([Path("/a/one.wav").as_uri(), Path("/b/two.wav").as_uri()], EMPTY_STARTUP_ID),
+ )
+ connection.send_and_get_reply.assert_called_once_with(build_call.return_value)
+
+
+class TestAnswers:
+ def test_an_answered_probe_reports_true(self) -> None:
+ with patch(f"{MODULE}.open_dbus_connection") as open_connection:
+ assert FileManager1Backend.answers()
+
+ open_connection.assert_called_once_with(bus="SESSION")
+
+ @patch(f"{MODULE}.open_dbus_connection", side_effect=_error_response())
+ def test_an_erroring_probe_reports_false(self, open_connection: MagicMock) -> None:
+ assert not FileManager1Backend.answers()
+
+ @patch(f"{MODULE}.open_dbus_connection", side_effect=AuthenticationError("denied"))
+ def test_an_unreachable_bus_reports_false(self, open_connection: MagicMock) -> None:
+ assert not FileManager1Backend.answers()
diff --git a/tests/unit/sampletones_shared/utils/system/reveal/test_grouped.py b/tests/unit/sampletones_shared/utils/system/reveal/test_grouped.py
new file mode 100644
index 000000000..d4e15c2f6
--- /dev/null
+++ b/tests/unit/sampletones_shared/utils/system/reveal/test_grouped.py
@@ -0,0 +1,38 @@
+from pathlib import Path
+from unittest.mock import call, patch
+
+from sampletones_shared.utils.system.reveal.grouped import (
+ GroupedDirectoryBackend,
+ distinct_parents,
+)
+
+
+class TestDistinctParents:
+ def test_each_parent_once_in_first_seen_order(self) -> None:
+ paths = (
+ Path("/a/one.wav"),
+ Path("/a/two.wav"),
+ Path("/b/three.wav"),
+ Path("/a/four.wav"),
+ )
+
+ assert distinct_parents(paths) == (Path("/a"), Path("/b"))
+
+ def test_paths_in_one_directory_share_their_parent(self) -> None:
+ paths = (Path("/a/one.wav"), Path("/a/two.wav"))
+
+ assert distinct_parents(paths) == (Path("/a"),)
+
+
+class TestGroupedDirectoryBackend:
+ def test_opens_each_directory_once(self) -> None:
+ paths = (
+ Path("/a/one.wav"),
+ Path("/a/two.wav"),
+ Path("/b/three.wav"),
+ )
+
+ with patch("sampletones_shared.utils.system.reveal.grouped.open_path_in_explorer") as open_directory:
+ GroupedDirectoryBackend().open(paths)
+
+ assert open_directory.call_args_list == [call(Path("/a")), call(Path("/b"))]
diff --git a/tests/unit/sampletones_shared/utils/system/reveal/test_selection.py b/tests/unit/sampletones_shared/utils/system/reveal/test_selection.py
new file mode 100644
index 000000000..5c8f3179d
--- /dev/null
+++ b/tests/unit/sampletones_shared/utils/system/reveal/test_selection.py
@@ -0,0 +1,86 @@
+from pathlib import Path
+from typing import Callable, Final
+from unittest.mock import patch
+
+import pytest
+
+from sampletones_shared.utils.system.modules import JEEPNEY_MODULE, module_available
+from sampletones_shared.utils.system.reveal.grouped import GroupedDirectoryBackend
+from sampletones_shared.utils.system.reveal.selection import (
+ open_paths_in_explorer,
+ select_reveal_backend,
+)
+from sampletones_shared.utils.system.system import System
+
+MODULE = "sampletones_shared.utils.system.reveal.selection"
+BACKEND_MODULE = "sampletones_shared.utils.system.reveal.file_manager1"
+
+JEEPNEY_INSTALLED: Final[bool] = module_available(JEEPNEY_MODULE)
+
+requires_jeepney = pytest.mark.skipif(
+ not JEEPNEY_INSTALLED,
+ reason="The FileManager1 backend stands on jeepney, which ships on Linux",
+)
+
+
+def _available(installed: bool) -> Callable[[str], bool]:
+ def resolver(module: str) -> bool:
+ return installed
+
+ return resolver
+
+
+class TestOpenPathsInExplorer:
+ def test_a_single_path_opens_the_explorer_on_it(self) -> None:
+ path = Path("/a/one.wav")
+
+ with patch(f"{MODULE}.open_path_in_explorer") as open_single:
+ open_paths_in_explorer([path])
+
+ open_single.assert_called_once_with(path)
+
+ def test_several_paths_go_through_the_selected_backend(self) -> None:
+ paths = (Path("/a/one.wav"), Path("/b/two.wav"))
+
+ with patch(f"{MODULE}.select_reveal_backend") as select:
+ open_paths_in_explorer(list(paths))
+
+ select.assert_called_once()
+ select.return_value.open.assert_called_once_with(paths)
+
+ def test_no_path_raises(self) -> None:
+ with pytest.raises(ValueError, match="At least one path is required"):
+ open_paths_in_explorer([])
+
+
+class TestSelectRevealBackend:
+ def test_grouped_on_non_linux_systems(self) -> None:
+ with patch(f"{MODULE}.System.current", return_value=System.WINDOWS):
+ assert isinstance(select_reveal_backend(), GroupedDirectoryBackend)
+
+ def test_grouped_when_jeepney_is_absent(self) -> None:
+ with (
+ patch(f"{MODULE}.System.current", return_value=System.LINUX),
+ patch(f"{MODULE}.module_available", side_effect=_available(installed=False)),
+ ):
+ assert isinstance(select_reveal_backend(), GroupedDirectoryBackend)
+
+ @requires_jeepney
+ def test_file_manager1_when_the_service_answers(self) -> None:
+ from sampletones_shared.utils.system.reveal.file_manager1 import FileManager1Backend
+
+ with (
+ patch(f"{MODULE}.System.current", return_value=System.LINUX),
+ patch(f"{MODULE}.module_available", side_effect=_available(installed=True)),
+ patch(f"{BACKEND_MODULE}.FileManager1Backend.answers", return_value=True),
+ ):
+ assert isinstance(select_reveal_backend(), FileManager1Backend)
+
+ @requires_jeepney
+ def test_grouped_when_the_service_stays_silent(self) -> None:
+ with (
+ patch(f"{MODULE}.System.current", return_value=System.LINUX),
+ patch(f"{MODULE}.module_available", side_effect=_available(installed=True)),
+ patch(f"{BACKEND_MODULE}.FileManager1Backend.answers", return_value=False),
+ ):
+ assert isinstance(select_reveal_backend(), GroupedDirectoryBackend)
diff --git a/tests/unit/sampletones_shared/utils/system/test_paths.py b/tests/unit/sampletones_shared/utils/system/test_paths.py
index b0ab5a04b..821ea056b 100644
--- a/tests/unit/sampletones_shared/utils/system/test_paths.py
+++ b/tests/unit/sampletones_shared/utils/system/test_paths.py
@@ -9,6 +9,7 @@
from sampletones_shared.utils.system.paths import (
DEFAULT_MAX_FILENAME_DISPLAY,
ensure_suffix,
+ first_missing,
get_directory,
get_filename,
open_directory_in_explorer_linux,
@@ -18,6 +19,7 @@
shorten_filename,
shorten_path,
to_path,
+ to_paths,
)
from sampletones_shared.utils.system.system import System
from tests.suite.base import BaseTestSuite
@@ -25,6 +27,40 @@
from tests.suite.errors import expect_error
+class TestFirstMissing:
+ def test_answers_the_first_path_that_names_no_file(self, tmp_path: Path) -> None:
+ existing = tmp_path / "here.wav"
+ existing.touch()
+ missing = tmp_path / "gone.wav"
+ later_missing = tmp_path / "also_gone.wav"
+
+ assert first_missing((existing, missing, later_missing)) == missing
+
+ def test_answers_none_when_every_path_stands(self, tmp_path: Path) -> None:
+ first = tmp_path / "one.wav"
+ second = tmp_path / "two.wav"
+ first.touch()
+ second.touch()
+
+ assert first_missing((first, second)) is None
+
+
+class TestToPaths:
+ def test_one_path_becomes_a_one_tuple(self) -> None:
+ assert to_paths(Path("/a/one.wav")) == (Path("/a/one.wav"),)
+
+ def test_several_paths_stay_as_they_are(self) -> None:
+ paths = (Path("/a/one.wav"), Path("/b/two.wav"))
+
+ assert to_paths(paths) == paths
+
+ def test_an_absent_location_is_empty(self) -> None:
+ assert to_paths(None) == ()
+
+ def test_strings_become_paths(self) -> None:
+ assert to_paths("/a/one.wav") == (Path("/a/one.wav"),)
+
+
class TestToPath(BaseTestSuite):
@dataclass(frozen=True, kw_only=True)
class TestCase(BaseRegularTestCase):
diff --git a/tests/unit/sampletones_shared/utils/system/test_programs.py b/tests/unit/sampletones_shared/utils/system/test_programs.py
new file mode 100644
index 000000000..f59899de6
--- /dev/null
+++ b/tests/unit/sampletones_shared/utils/system/test_programs.py
@@ -0,0 +1,51 @@
+from pathlib import Path
+from typing import Dict, Final
+from unittest.mock import patch
+
+import pytest
+
+from sampletones_shared.utils.system.programs import (
+ locate_program,
+ missing_program_message,
+)
+from sampletones_shared.utils.system.system import System
+
+HINTS: Final[Dict[System, str]] = {
+ System.LINUX: "sudo apt install tool",
+ System.MACOS: "brew install tool",
+ System.WINDOWS: "install tool and add it to PATH",
+}
+
+
+class TestLocateProgram:
+ """Where a program is installed, or nothing where this system carries none."""
+
+ def test_an_installed_program_reports_where_it_is(self) -> None:
+ with patch("shutil.which", return_value="/usr/bin/tool"):
+ assert locate_program("tool") == Path("/usr/bin/tool")
+
+ def test_an_absent_program_reports_nothing(self) -> None:
+ with patch("shutil.which", return_value=None):
+ assert locate_program("tool") is None
+
+
+class TestMissingProgramMessage:
+ """What to report when a program the project reaches for is absent."""
+
+ def test_the_message_names_the_program_its_purpose_and_the_install_command(self) -> None:
+ with patch("platform.system", return_value="Linux"):
+ message = missing_program_message("tool", "waves are rendered with tool", HINTS)
+
+ assert message == "tool is missing: waves are rendered with tool (sudo apt install tool)"
+
+ def test_every_system_names_its_own_command(self) -> None:
+ with patch("platform.system", return_value="Darwin"):
+ assert HINTS[System.MACOS] in missing_program_message("tool", "purpose", HINTS)
+
+ with patch("platform.system", return_value="Windows"):
+ assert HINTS[System.WINDOWS] in missing_program_message("tool", "purpose", HINTS)
+
+ def test_a_system_with_no_hint_raises(self) -> None:
+ with patch("platform.system", return_value="Linux"):
+ with pytest.raises(KeyError):
+ missing_program_message("tool", "purpose", {System.WINDOWS: "installer"})
diff --git a/tests/unit/sampletones_shared/utils/test_color.py b/tests/unit/sampletones_shared/utils/test_color.py
index a4de4b939..959d1b51d 100644
--- a/tests/unit/sampletones_shared/utils/test_color.py
+++ b/tests/unit/sampletones_shared/utils/test_color.py
@@ -181,7 +181,7 @@ def test_a_transparent_overlay_leaves_the_base(self) -> None:
def test_a_transparent_base_leaves_the_overlay(self) -> None:
assert composite(self.TRANSPARENT, self.GREEN) == self.GREEN
- def test_two_transparent_colours_stay_transparent(self) -> None:
+ def test_two_transparent_colors_stay_transparent(self) -> None:
assert composite(self.TRANSPARENT, self.TRANSPARENT) == self.TRANSPARENT
def test_stacked_washes_cover_more_than_either_alone(self) -> None:
diff --git a/tests/unit/sampletones_shared/utils/test_frequencies.py b/tests/unit/sampletones_shared/utils/test_frequencies.py
new file mode 100644
index 000000000..0ed2d26ea
--- /dev/null
+++ b/tests/unit/sampletones_shared/utils/test_frequencies.py
@@ -0,0 +1,680 @@
+from dataclasses import dataclass
+from typing import Any, Type, Union
+
+import numpy as np
+import pytest
+
+from sampletones_shared.constants.music import LIMIT_MAX_PITCH, LIMIT_MIN_PITCH
+from sampletones_shared.utils.frequencies import (
+ frequency_to_pitch,
+ pitch_to_frequency,
+ validate_frequency,
+ validate_pitch,
+)
+from tests.suite.base import BaseTestSuite
+from tests.suite.case import BaseRegularTestCase
+from tests.suite.errors import expect_error
+
+
+class TestValidatePitch(BaseTestSuite):
+ @dataclass(frozen=True, kw_only=True)
+ class TestCase(BaseRegularTestCase):
+ expected: Union[None, Type[Exception]]
+ pitch: Any
+
+ test_cases = (
+ TestCase(
+ pitch=LIMIT_MIN_PITCH,
+ expected=None,
+ label="exactly_min_limit",
+ ),
+ TestCase(
+ pitch=LIMIT_MAX_PITCH,
+ expected=None,
+ label="exactly_max_limit",
+ ),
+ TestCase(
+ pitch=69,
+ expected=None,
+ label="middle_valid_pitch",
+ ),
+ TestCase(
+ pitch=60,
+ expected=None,
+ label="another_valid_pitch",
+ ),
+ TestCase(
+ pitch=(LIMIT_MIN_PITCH + LIMIT_MAX_PITCH) // 2,
+ expected=None,
+ label="middle_of_range",
+ ),
+ TestCase(
+ pitch=LIMIT_MIN_PITCH - 1,
+ expected=ValueError,
+ label="one_below_min",
+ ),
+ TestCase(
+ pitch=LIMIT_MAX_PITCH + 1,
+ expected=ValueError,
+ label="one_above_max",
+ ),
+ TestCase(
+ pitch=0,
+ expected=ValueError,
+ label="zero",
+ ),
+ TestCase(
+ pitch=-100,
+ expected=ValueError,
+ label="large_negative",
+ ),
+ TestCase(
+ pitch=200,
+ expected=ValueError,
+ label="large_positive",
+ ),
+ TestCase(
+ pitch="60",
+ expected=TypeError,
+ label="pitch_string",
+ ),
+ TestCase(
+ pitch=None,
+ expected=TypeError,
+ label="pitch_none",
+ ),
+ TestCase(
+ pitch=60.5,
+ expected=TypeError,
+ label="pitch_float",
+ ),
+ TestCase(
+ pitch=[60],
+ expected=TypeError,
+ label="pitch_list",
+ ),
+ TestCase(
+ pitch={"pitch": 60},
+ expected=TypeError,
+ label="pitch_dict",
+ ),
+ )
+
+ @pytest.mark.parametrize(
+ "test_case",
+ test_cases,
+ ids=lambda test_case: test_case.label,
+ )
+ def test_validate_pitch(self, test_case: TestCase) -> None:
+ if expect_error(validate_pitch, test_case.expected, test_case.pitch):
+ return
+
+ validate_pitch(test_case.pitch)
+
+
+class TestValidateFrequency:
+ @dataclass(frozen=True, kw_only=True)
+ class TestCase(BaseRegularTestCase):
+ expected: Union[None, Type[Exception]]
+ frequency: Any
+
+ test_cases = (
+ TestCase(
+ frequency=440.0,
+ expected=None,
+ label="valid_float",
+ ),
+ TestCase(
+ frequency=440,
+ expected=None,
+ label="valid_int",
+ ),
+ TestCase(
+ frequency=1.0,
+ expected=None,
+ label="one_hz",
+ ),
+ TestCase(
+ frequency=0.001,
+ expected=None,
+ label="very_small_positive",
+ ),
+ TestCase(
+ frequency=100000.0,
+ expected=None,
+ label="very_large_positive",
+ ),
+ TestCase(
+ frequency=1e-100,
+ expected=None,
+ label="extremely_small_positive",
+ ),
+ TestCase(
+ frequency=1e100,
+ expected=None,
+ label="extremely_large_positive",
+ ),
+ TestCase(
+ frequency=0.0,
+ expected=ValueError,
+ label="zero",
+ ),
+ TestCase(
+ frequency=-1.0,
+ expected=ValueError,
+ label="negative",
+ ),
+ TestCase(
+ frequency=-440.0,
+ expected=ValueError,
+ label="negative_440",
+ ),
+ TestCase(
+ frequency=np.inf,
+ expected=ValueError,
+ label="positive_infinity",
+ ),
+ TestCase(
+ frequency=-np.inf,
+ expected=ValueError,
+ label="negative_infinity",
+ ),
+ TestCase(
+ frequency=np.nan,
+ expected=ValueError,
+ label="nan",
+ ),
+ TestCase(
+ frequency="440",
+ expected=TypeError,
+ label="frequency_string",
+ ),
+ TestCase(
+ frequency=None,
+ expected=TypeError,
+ label="frequency_none",
+ ),
+ TestCase(
+ frequency=[440.0],
+ expected=TypeError,
+ label="frequency_list",
+ ),
+ TestCase(
+ frequency={"freq": 440.0},
+ expected=TypeError,
+ label="frequency_dict",
+ ),
+ )
+
+ @pytest.mark.parametrize(
+ "test_case",
+ test_cases,
+ ids=lambda test_case: test_case.label,
+ )
+ def test_validate_frequency(self, test_case: TestCase) -> None:
+ if expect_error(
+ validate_frequency,
+ test_case.expected,
+ test_case.frequency,
+ ):
+ return
+
+ validate_frequency(test_case.frequency)
+
+
+class TestPitchToFrequency(BaseTestSuite):
+ @dataclass(frozen=True, kw_only=True)
+ class TestCase(BaseRegularTestCase):
+ expected: Union[float, Type[Exception]]
+ pitch: Any
+ a4_frequency: Any
+ a4_pitch: Any
+
+ test_cases = (
+ TestCase(
+ pitch=69,
+ a4_frequency=440.0,
+ a4_pitch=69,
+ expected=440.0,
+ label="a4_default_tuning",
+ ),
+ TestCase(
+ pitch=81,
+ a4_frequency=440.0,
+ a4_pitch=69,
+ expected=880.0,
+ label="a5_one_octave_above",
+ ),
+ TestCase(
+ pitch=57,
+ a4_frequency=440.0,
+ a4_pitch=69,
+ expected=220.0,
+ label="a3_one_octave_below",
+ ),
+ TestCase(
+ pitch=60,
+ a4_frequency=440.0,
+ a4_pitch=69,
+ expected=261.6255653005986,
+ label="middle_c",
+ ),
+ TestCase(
+ pitch=69,
+ a4_frequency=432.0,
+ a4_pitch=69,
+ expected=432.0,
+ label="a4_alternative_tuning",
+ ),
+ TestCase(
+ pitch=69,
+ a4_frequency=440.0,
+ a4_pitch=69,
+ expected=440.0,
+ label="reference_pitch_returns_reference_frequency",
+ ),
+ TestCase(
+ pitch=LIMIT_MIN_PITCH,
+ a4_frequency=440.0,
+ a4_pitch=69,
+ expected=32.70319566257483,
+ label="min_pitch_boundary",
+ ),
+ TestCase(
+ pitch=LIMIT_MAX_PITCH,
+ a4_frequency=440.0,
+ a4_pitch=69,
+ expected=12543.853951415975,
+ label="max_pitch_boundary",
+ ),
+ TestCase(
+ pitch=0,
+ a4_frequency=440.0,
+ a4_pitch=69,
+ expected=ValueError,
+ label="pitch_zero",
+ ),
+ TestCase(
+ pitch=-12,
+ a4_frequency=440.0,
+ a4_pitch=69,
+ expected=ValueError,
+ label="negative_pitch",
+ ),
+ TestCase(
+ pitch=127,
+ a4_frequency=440.0,
+ a4_pitch=69,
+ expected=12543.853951415975,
+ label="max_midi_pitch",
+ ),
+ TestCase(
+ pitch=60,
+ a4_frequency=432,
+ a4_pitch=69,
+ expected=256.86873684058776,
+ label="middle_c_alternative_tuning",
+ ),
+ TestCase(
+ pitch=60,
+ a4_frequency=440.0,
+ a4_pitch=60,
+ expected=440.0,
+ label="different_reference_pitch",
+ ),
+ TestCase(
+ pitch=72,
+ a4_frequency=440.0,
+ a4_pitch=60,
+ expected=880.0,
+ label="octave_above_different_reference",
+ ),
+ TestCase(
+ pitch=60,
+ a4_frequency=880.0,
+ a4_pitch=69,
+ expected=523.2511306011972,
+ label="double_reference_frequency",
+ ),
+ TestCase(
+ pitch=69,
+ a4_frequency=220.0,
+ a4_pitch=69,
+ expected=220.0,
+ label="half_reference_frequency",
+ ),
+ TestCase(
+ pitch="not_an_int",
+ a4_frequency=440.0,
+ a4_pitch=69,
+ expected=TypeError,
+ label="pitch_string",
+ ),
+ TestCase(
+ pitch=None,
+ a4_frequency=440.0,
+ a4_pitch=69,
+ expected=TypeError,
+ label="pitch_none",
+ ),
+ TestCase(
+ pitch=[60],
+ a4_frequency=440.0,
+ a4_pitch=69,
+ expected=TypeError,
+ label="pitch_list",
+ ),
+ TestCase(
+ pitch={"pitch": 60},
+ a4_frequency=440.0,
+ a4_pitch=69,
+ expected=TypeError,
+ label="pitch_dict",
+ ),
+ TestCase(
+ pitch=60,
+ a4_frequency="440",
+ a4_pitch=69,
+ expected=TypeError,
+ label="a4_frequency_string",
+ ),
+ TestCase(
+ pitch=60,
+ a4_frequency=None,
+ a4_pitch=69,
+ expected=TypeError,
+ label="a4_frequency_none",
+ ),
+ TestCase(
+ pitch=60,
+ a4_frequency=[440.0],
+ a4_pitch=69,
+ expected=TypeError,
+ label="a4_frequency_list",
+ ),
+ TestCase(
+ pitch=60,
+ a4_frequency=440.0,
+ a4_pitch="69",
+ expected=TypeError,
+ label="a4_pitch_string",
+ ),
+ TestCase(
+ pitch=60,
+ a4_frequency=440.0,
+ a4_pitch=None,
+ expected=TypeError,
+ label="a4_pitch_none",
+ ),
+ TestCase(
+ pitch=60,
+ a4_frequency=440.0,
+ a4_pitch=[69],
+ expected=TypeError,
+ label="a4_pitch_list",
+ ),
+ TestCase(
+ pitch=60,
+ a4_frequency=np.inf,
+ a4_pitch=69,
+ expected=ValueError,
+ label="a4_frequency_inf",
+ ),
+ TestCase(
+ pitch=60,
+ a4_frequency=-440.0,
+ a4_pitch=69,
+ expected=ValueError,
+ label="a4_frequency_negative",
+ ),
+ TestCase(
+ pitch=60,
+ a4_frequency=0.0,
+ a4_pitch=69,
+ expected=ValueError,
+ label="a4_frequency_zero",
+ ),
+ TestCase(
+ pitch=60,
+ a4_frequency=np.nan,
+ a4_pitch=69,
+ expected=ValueError,
+ label="a4_frequency_nan",
+ ),
+ )
+
+ @pytest.mark.parametrize(
+ "test_case",
+ test_cases,
+ ids=lambda test_case: test_case.label,
+ )
+ def test_pitch_to_frequency(self, test_case: TestCase) -> None:
+ if expect_error(
+ pitch_to_frequency,
+ test_case.expected,
+ test_case.pitch,
+ test_case.a4_frequency,
+ test_case.a4_pitch,
+ ):
+ return
+
+ result = pitch_to_frequency(
+ test_case.pitch,
+ test_case.a4_frequency,
+ test_case.a4_pitch,
+ )
+ if isinstance(test_case.expected, float) and np.isnan(test_case.expected):
+ assert np.isnan(result)
+ else:
+ assert result == pytest.approx(test_case.expected, rel=1e-9)
+ assert isinstance(result, float)
+
+
+class TestFrequencyToPitch(BaseTestSuite):
+ @dataclass(frozen=True, kw_only=True)
+ class TestCase(BaseRegularTestCase):
+ expected: Union[int, Type[Exception]]
+ frequency: Any
+ a4_frequency: Any
+ a4_pitch: Any
+
+ test_cases = (
+ TestCase(
+ frequency=440.0,
+ a4_frequency=440.0,
+ a4_pitch=69,
+ expected=69,
+ label="a4_frequency",
+ ),
+ TestCase(
+ frequency=880.0,
+ a4_frequency=440.0,
+ a4_pitch=69,
+ expected=81,
+ label="a5_frequency",
+ ),
+ TestCase(
+ frequency=261.63,
+ a4_frequency=440.0,
+ a4_pitch=69,
+ expected=60,
+ label="middle_c_approximate",
+ ),
+ TestCase(
+ frequency=0.0,
+ a4_frequency=440.0,
+ a4_pitch=69,
+ expected=ValueError,
+ label="zero_frequency",
+ ),
+ TestCase(
+ frequency=-100.0,
+ a4_frequency=440.0,
+ a4_pitch=69,
+ expected=ValueError,
+ label="negative_frequency",
+ ),
+ TestCase(
+ frequency=220.0,
+ a4_frequency=440.0,
+ a4_pitch=69,
+ expected=57,
+ label="a3_frequency",
+ ),
+ TestCase(
+ frequency=440.0,
+ a4_frequency=440.0,
+ a4_pitch=69,
+ expected=69,
+ label="reference_frequency_returns_reference_pitch",
+ ),
+ TestCase(
+ frequency=32.70319566257483,
+ a4_frequency=440.0,
+ a4_pitch=69,
+ expected=LIMIT_MIN_PITCH,
+ label="min_frequency_boundary",
+ ),
+ TestCase(
+ frequency=1e-10,
+ a4_frequency=440.0,
+ a4_pitch=69,
+ expected=ValueError,
+ label="very_small_positive_frequency",
+ ),
+ TestCase(
+ frequency=100000.0,
+ a4_frequency=440.0,
+ a4_pitch=69,
+ expected=ValueError,
+ label="very_high_frequency",
+ ),
+ TestCase(
+ frequency=432,
+ a4_frequency=432,
+ a4_pitch=69,
+ expected=69,
+ label="alternative_tuning",
+ ),
+ TestCase(
+ frequency=440.0,
+ a4_frequency=432,
+ a4_pitch=69,
+ expected=69,
+ label="different_reference_frequency",
+ ),
+ TestCase(
+ frequency=440,
+ a4_frequency=440.0,
+ a4_pitch=60,
+ expected=60,
+ label="different_reference_pitch",
+ ),
+ TestCase(
+ frequency=880.0,
+ a4_frequency=220.0,
+ a4_pitch=69,
+ expected=93,
+ label="quadruple_reference_frequency",
+ ),
+ TestCase(
+ frequency="440",
+ a4_frequency=440.0,
+ a4_pitch=69,
+ expected=TypeError,
+ label="frequency_string",
+ ),
+ TestCase(
+ frequency=None,
+ a4_frequency=440.0,
+ a4_pitch=69,
+ expected=TypeError,
+ label="frequency_none",
+ ),
+ TestCase(
+ frequency=[440.0],
+ a4_frequency=440.0,
+ a4_pitch=69,
+ expected=TypeError,
+ label="frequency_list",
+ ),
+ TestCase(
+ frequency={"freq": 440.0},
+ a4_frequency=440.0,
+ a4_pitch=69,
+ expected=TypeError,
+ label="frequency_dict",
+ ),
+ TestCase(
+ frequency=440.0,
+ a4_frequency="440",
+ a4_pitch=69,
+ expected=TypeError,
+ label="a4_frequency_string",
+ ),
+ TestCase(
+ frequency=440.0,
+ a4_frequency=None,
+ a4_pitch=69,
+ expected=TypeError,
+ label="a4_frequency_none",
+ ),
+ TestCase(
+ frequency=440.0,
+ a4_frequency=440.0,
+ a4_pitch="69",
+ expected=TypeError,
+ label="a4_pitch_string",
+ ),
+ TestCase(
+ frequency=440.0,
+ a4_frequency=440.0,
+ a4_pitch=None,
+ expected=TypeError,
+ label="a4_pitch_none",
+ ),
+ TestCase(
+ frequency=np.inf,
+ a4_frequency=440.0,
+ a4_pitch=69,
+ expected=ValueError,
+ label="frequency_inf",
+ ),
+ TestCase(
+ frequency=-np.inf,
+ a4_frequency=440.0,
+ a4_pitch=69,
+ expected=ValueError,
+ label="frequency_negative_inf",
+ ),
+ TestCase(
+ frequency=np.nan,
+ a4_frequency=440.0,
+ a4_pitch=69,
+ expected=ValueError,
+ label="frequency_nan",
+ ),
+ )
+
+ @pytest.mark.parametrize(
+ "test_case",
+ test_cases,
+ ids=lambda test_case: test_case.label,
+ )
+ def test_frequency_to_pitch(self, test_case: TestCase) -> None:
+ if expect_error(
+ frequency_to_pitch,
+ test_case.expected,
+ test_case.frequency,
+ test_case.a4_frequency,
+ test_case.a4_pitch,
+ ):
+ return
+
+ result = frequency_to_pitch(
+ test_case.frequency,
+ test_case.a4_frequency,
+ test_case.a4_pitch,
+ )
+ assert result == test_case.expected
+ assert isinstance(result, int)
diff --git a/tests/unit/sampletones_shared/utils/test_progress.py b/tests/unit/sampletones_shared/utils/test_progress.py
new file mode 100644
index 000000000..c891154d4
--- /dev/null
+++ b/tests/unit/sampletones_shared/utils/test_progress.py
@@ -0,0 +1,88 @@
+from dataclasses import dataclass
+from typing import Final, List, Tuple
+
+import pytest
+
+from sampletones_shared.utils.progress import (
+ PROGRESS_STEPS,
+ ReportRate,
+ report_interval,
+ silent_reporter,
+)
+from tests.suite.base import BaseTestSuite
+from tests.suite.case import BaseAutolabelTestCase
+
+LONG_STAGE: Final[int] = PROGRESS_STEPS * 100
+SHORT_STAGE: Final[int] = 3
+UNMEASURED_STAGE: Final[int] = 0
+ONE_STEP: Final[int] = 1
+
+
+class TestHowOftenAStageIsWorthReporting(BaseTestSuite):
+ """The spacing between reports follows the length of the stage, never the size of its steps."""
+
+ @dataclass(frozen=True, kw_only=True)
+ class TestCase(BaseAutolabelTestCase):
+ expected: int
+ total: int
+
+ @property
+ def label(self) -> str:
+ return f"a_stage_counting_to_{self.total}"
+
+ test_cases = (
+ TestCase(total=UNMEASURED_STAGE, expected=ONE_STEP),
+ TestCase(total=SHORT_STAGE, expected=ONE_STEP),
+ TestCase(total=PROGRESS_STEPS, expected=ONE_STEP),
+ TestCase(total=LONG_STAGE, expected=LONG_STAGE // PROGRESS_STEPS),
+ )
+
+ @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label)
+ def test_a_stage_is_reported_over_a_fixed_number_of_steps(self, test_case: TestCase) -> None:
+ assert report_interval(test_case.total) == test_case.expected
+
+ def test_a_stage_shorter_than_the_steps_reports_every_count(self) -> None:
+ rate = ReportRate(SHORT_STAGE)
+
+ assert [rate.take(completed) for completed in range(SHORT_STAGE + 1)] == [True] * (SHORT_STAGE + 1)
+
+
+class TestTakingAReading(BaseTestSuite):
+ """A rate takes the readings that are due and lets the rest of a stage's steps pass."""
+
+ def test_a_long_stage_is_reported_about_as_often_as_it_has_steps(self) -> None:
+ rate = ReportRate(LONG_STAGE)
+
+ taken = [completed for completed in range(LONG_STAGE + 1) if rate.take(completed)]
+
+ assert len(taken) == pytest.approx(PROGRESS_STEPS + 1, abs=ONE_STEP)
+
+ def test_a_stage_landing_on_its_total_is_always_reported(self) -> None:
+ rate = ReportRate(LONG_STAGE)
+ rate.take(0)
+
+ assert rate.take(LONG_STAGE)
+
+ def test_a_count_falling_toward_its_total_is_a_step_the_same_way(self) -> None:
+ rate = ReportRate(LONG_STAGE)
+ rate.take(LONG_STAGE)
+
+ assert rate.take(LONG_STAGE // 2)
+
+ def test_a_reading_is_measured_from_the_one_before_it(self) -> None:
+ rate = ReportRate(LONG_STAGE)
+ interval = report_interval(LONG_STAGE)
+ rate.take(0)
+
+ assert not rate.take(interval - ONE_STEP)
+ assert rate.take(interval)
+
+
+class TestACallerWatchingNothing(BaseTestSuite):
+ """A run reported to nobody still asks whether it goes on, and hears that it does."""
+
+ def test_the_silent_reporter_lets_every_run_through(self) -> None:
+ readings: Tuple[object, ...] = ("a stage", 1, None)
+ answers: List[bool] = [silent_reporter(reading) for reading in readings]
+
+ assert answers == [True] * len(readings)
diff --git a/tests/unit/scripts/checks/test_import_boundary.py b/tests/unit/scripts/checks/test_import_boundary.py
index e2aa92fc7..2e0798f3b 100644
--- a/tests/unit/scripts/checks/test_import_boundary.py
+++ b/tests/unit/scripts/checks/test_import_boundary.py
@@ -1,134 +1,21 @@
from pathlib import Path
-from typing import Final, List
+from typing import Final
import pytest
-from sampletones_shared.meta.source.modules import source_paths
from tests.suite.scripts import load_script
+from tests.suite.source import write_module
check_import_boundary = load_script("checks/import_boundary.py")
-LOGIC_RULE: Final[str] = "logic/**/*.py"
+APPLICATION: Final[str] = "sampletones_application"
-FORBIDDEN_IMPORT: Final[str] = "import dearpygui.dearpygui as dpg\n"
-CONTRACT_IMPORT: Final[str] = "from sampletones_application.services.result import ServiceResult\n"
+VISUAL_IMPORT: Final[str] = "import dearpygui.dearpygui as dpg\n"
PLAIN_IMPORT: Final[str] = "from sampletones_core.project.project import Project\n"
-PANEL_SUFFIX: Final[str] = "def build() -> None:\n dpg.add_group(parent=SUF_PANEL_LEFT)\n"
-
-
-def write_module(directory: Path, name: str, body: str) -> Path:
- directory.mkdir(parents=True, exist_ok=True)
- path = directory / name
- path.write_text(body, encoding="utf-8")
- return path
-
-
-def swept(package: Path) -> List[Path]:
- return [path.resolve() for path in source_paths([package])]
-
-
-class TestRuleModules:
- def test_a_module_directly_under_the_rule_directory_is_reached(self, tmp_path: Path) -> None:
- """`logic/**/*.py` names `logic/direct.py` as surely as `logic/inner/deep.py`."""
- direct = write_module(tmp_path / "logic", "direct.py", PLAIN_IMPORT)
-
- reached = check_import_boundary.rule_modules(tmp_path, LOGIC_RULE, set(swept(tmp_path)), None)
-
- assert reached == [direct.resolve()]
-
- def test_a_module_nested_under_the_rule_directory_is_reached(self, tmp_path: Path) -> None:
- deep = write_module(tmp_path / "logic" / "inner", "deep.py", PLAIN_IMPORT)
-
- reached = check_import_boundary.rule_modules(tmp_path, LOGIC_RULE, set(swept(tmp_path)), None)
-
- assert reached == [deep.resolve()]
-
- def test_a_module_outside_the_rule_directory_stays_aside(self, tmp_path: Path) -> None:
- write_module(tmp_path / "services", "conversion.py", PLAIN_IMPORT)
-
- assert check_import_boundary.rule_modules(tmp_path, LOGIC_RULE, set(swept(tmp_path)), None) == []
-
- def test_a_selection_narrows_the_rule_to_the_files_it_names(self, tmp_path: Path) -> None:
- named = write_module(tmp_path / "logic", "named.py", PLAIN_IMPORT)
- write_module(tmp_path / "logic", "other.py", PLAIN_IMPORT)
-
- reached = check_import_boundary.rule_modules(
- tmp_path,
- LOGIC_RULE,
- set(swept(tmp_path)),
- {named.resolve()},
- )
-
- assert reached == [named.resolve()]
-
-
-class TestCheckBoundaries:
- def test_a_forbidden_import_is_reported(self, tmp_path: Path) -> None:
- write_module(tmp_path / "logic", "direct.py", FORBIDDEN_IMPORT)
-
- violations = check_import_boundary.check_boundaries(tmp_path, None)
-
- assert [violation.kind for violation in violations] == ["dearpygui"]
-
- def test_the_report_names_the_line_the_import_sits_on(self, tmp_path: Path) -> None:
- path = write_module(tmp_path / "logic", "direct.py", f"{PLAIN_IMPORT}{FORBIDDEN_IMPORT}")
-
- violations = check_import_boundary.check_boundaries(tmp_path, None)
-
- assert violations[0].location.startswith(f"{path}:2")
-
- def test_a_contract_module_stays_reachable(self, tmp_path: Path) -> None:
- """A layer reads another layer's data contract while its implementation stays out of reach."""
- write_module(tmp_path / "logic", "direct.py", CONTRACT_IMPORT)
-
- assert check_import_boundary.check_boundaries(tmp_path, None) == []
-
- def test_an_allowed_import_reports_nothing(self, tmp_path: Path) -> None:
- write_module(tmp_path / "logic", "direct.py", PLAIN_IMPORT)
-
- assert check_import_boundary.check_boundaries(tmp_path, None) == []
-
- def test_a_forbidden_token_is_reported(self, tmp_path: Path) -> None:
- write_module(tmp_path / "ui" / "panels", "left.py", PANEL_SUFFIX)
-
- violations = check_import_boundary.check_boundaries(tmp_path, None)
-
- assert len(violations) == 1
-
- def test_a_selection_narrows_the_check(self, tmp_path: Path) -> None:
- checked = write_module(tmp_path / "logic", "checked.py", FORBIDDEN_IMPORT)
- write_module(tmp_path / "logic", "other.py", FORBIDDEN_IMPORT)
-
- violations = check_import_boundary.check_boundaries(tmp_path, {checked.resolve()})
-
- assert len(violations) == 1
-
-
-class TestSweptRoots:
- """A root the sweep reads nothing under reports nothing, which reads as a clean tree."""
-
- def test_the_application_package_holds_modules(self) -> None:
- assert source_paths([check_import_boundary.APP_ROOT])
-
- def test_every_boundary_rule_reaches_a_module(self) -> None:
- package = check_import_boundary.APP_ROOT
- assert all(list(package.glob(rule.pattern)) for rule in check_import_boundary.RULES)
-
- def test_every_token_rule_reaches_a_module(self) -> None:
- package = check_import_boundary.APP_ROOT
- assert all(list(package.glob(rule.pattern)) for rule in check_import_boundary.TOKEN_RULES)
-
- def test_a_package_holding_no_module_stops_the_check(self, tmp_path: Path) -> None:
- with pytest.raises(FileNotFoundError):
- check_import_boundary.check_boundaries(tmp_path, None)
-
- def test_an_absent_package_stops_the_check(self, tmp_path: Path) -> None:
- with pytest.raises(NotADirectoryError):
- check_import_boundary.check_boundaries(tmp_path / "absent", None)
class TestMain:
- def test_the_repository_holds_its_layer_boundaries(self) -> None:
+ def test_the_repository_holds_its_import_boundaries(self) -> None:
assert check_import_boundary.main(["--all"]) == 0
def test_a_forbidden_import_is_reported_where_it_sits(
@@ -136,9 +23,9 @@ def test_a_forbidden_import_is_reported_where_it_sits(
tmp_path: Path,
capsys: pytest.CaptureFixture[str],
) -> None:
- path = write_module(tmp_path / "logic", "direct.py", FORBIDDEN_IMPORT)
+ path = write_module(tmp_path / APPLICATION / "logic", "direct.py", VISUAL_IMPORT)
- exit_code = check_import_boundary.main(["--all", "--package", str(tmp_path)])
+ exit_code = check_import_boundary.main(["--all", "--source", str(tmp_path)])
assert exit_code == 1
error = capsys.readouterr().err
@@ -150,8 +37,8 @@ def test_named_files_narrow_the_run_to_themselves(
tmp_path: Path,
capsys: pytest.CaptureFixture[str],
) -> None:
- write_module(tmp_path / "logic", "reported.py", FORBIDDEN_IMPORT)
- clean = write_module(tmp_path / "logic", "clean.py", PLAIN_IMPORT)
+ write_module(tmp_path / APPLICATION / "logic", "reported.py", VISUAL_IMPORT)
+ clean = write_module(tmp_path / APPLICATION / "logic", "clean.py", PLAIN_IMPORT)
- assert check_import_boundary.main([str(clean), "--package", str(tmp_path)]) == 0
+ assert check_import_boundary.main([str(clean), "--source", str(tmp_path)]) == 0
assert capsys.readouterr().err == ""
diff --git a/tests/unit/scripts/checks/test_palette_colors.py b/tests/unit/scripts/checks/test_palette_colors.py
index 2056eb0d4..9e13f2873 100644
--- a/tests/unit/scripts/checks/test_palette_colors.py
+++ b/tests/unit/scripts/checks/test_palette_colors.py
@@ -70,7 +70,7 @@ def test_an_attribute_assigned_the_resolved_value_is_reported(self) -> None:
def test_the_report_names_the_assignment_line(self) -> None:
assert locations(PANEL_SOURCE) == [f"{PANEL_MODULE}:4", f"{PANEL_MODULE}:6"]
- def test_an_attribute_holding_the_palette_colour_passes(self) -> None:
+ def test_an_attribute_holding_the_palette_color_passes(self) -> None:
source = "class GUIPanel:\n def __init__(self, layout) -> None:\n self._c = layout.colors\n"
assert not messages(source)
@@ -86,7 +86,7 @@ def test_a_local_holding_the_resolved_value_passes(self) -> None:
class TestUnregisteredThemeColors:
- def test_a_theme_colour_filled_directly_is_reported(
+ def test_a_theme_color_filled_directly_is_reported(
self,
module_helpers: Tuple[Path, str],
) -> None:
@@ -135,14 +135,14 @@ def test_the_palettes_sit_inside_the_configuration_package(self) -> None:
class TestLiteralColors:
- def test_a_hex_colour_outside_the_palettes_is_reported(self, tmp_path: Path) -> None:
+ def test_a_hex_color_outside_the_palettes_is_reported(self, tmp_path: Path) -> None:
(tmp_path / "settings.yaml").write_text(LAYOUT_FILE)
findings = check_palette_colors.find_literal_colors(tmp_path, tmp_path / "palettes")
assert [finding.location for finding in findings] == [f"{tmp_path / 'settings.yaml'}:2"]
- def test_a_palette_carries_its_colours_as_values(self, tmp_path: Path) -> None:
+ def test_a_palette_carries_its_colors_as_values(self, tmp_path: Path) -> None:
palettes = tmp_path / "palettes"
palettes.mkdir()
(palettes / "studio.yaml").write_text(PALETTE_FILE)
diff --git a/tests/unit/scripts/checks/test_shortcut_actions.py b/tests/unit/scripts/checks/test_shortcut_actions.py
new file mode 100644
index 000000000..569d95c5a
--- /dev/null
+++ b/tests/unit/scripts/checks/test_shortcut_actions.py
@@ -0,0 +1,117 @@
+from pathlib import Path
+from typing import Final, List, Set
+
+from sampletones_application.utils.gui.shortcuts.ids import (
+ FAMILY_SHORTCUT_IDS,
+ SHORTCUT_IDS_BY_NAME,
+ ShortcutCategory,
+ ShortcutId,
+)
+from sampletones_shared.meta.source.modules import SourceModule
+from tests.suite.scripts import load_script
+from tests.suite.source import parse_source
+
+check_shortcut_actions = load_script("checks/shortcut_actions.py")
+
+SCHEME: Final[Path] = Path("keybindings/default.yaml")
+BOUND_ACTION: Final[ShortcutId] = ShortcutId.NEW_PROJECT
+FAMILY_ACTION: Final[ShortcutId] = ShortcutId.EXPORT_PROJECT_FAMITRACKER
+PANEL_ACTION: Final[ShortcutId] = ShortcutId.TRACKER_NEXT_ROW
+DIALOG_ACTION: Final[ShortcutId] = ShortcutId.DIALOG_ACTIVATE
+
+SHELL_SOURCE: Final[str] = """
+def bind(bindings):
+ return {
+ ShortcutId.NEW_PROJECT: bindings.new_project,
+ ShortcutId.OPEN_PROJECT: bindings.open_project,
+ }
+"""
+
+
+def _module(source: str) -> SourceModule:
+ return SourceModule(path=Path("module.py"), tree=parse_source(source))
+
+
+def _kinds(findings: List[object]) -> Set[str]:
+ return {finding.kind for finding in findings} # type: ignore[attr-defined]
+
+
+class TestWhereACallIsFound:
+ """An action reaches its call either as a binding of its own or as a member of a family."""
+
+ def test_a_binding_map_names_its_actions_as_keys(self) -> None:
+ """The shell builds its map from the bindings it is handed, so the map is read as source."""
+ assert check_shortcut_actions.mapping_keys(_module(SHELL_SOURCE)) == {"NEW_PROJECT", "OPEN_PROJECT"}
+
+ def test_a_family_declares_the_actions_it_dispatches(self) -> None:
+ """A family is declared rather than recognized, so what counts as one is never guessed."""
+ assert FAMILY_ACTION in FAMILY_SHORTCUT_IDS
+
+ def test_the_lookup_of_every_action_by_name_is_no_family(self) -> None:
+ """`SHORTCUT_IDS_BY_NAME` answers with every action; reading it as a family would excuse
+ every action from stating a call of its own."""
+ assert set(SHORTCUT_IDS_BY_NAME.values()) - FAMILY_SHORTCUT_IDS
+
+
+class TestUnansweredActions:
+ def test_an_action_a_scheme_passes_over_is_reported(self) -> None:
+ findings = check_shortcut_actions.unanswered_actions({SCHEME: set()}, (BOUND_ACTION,))
+
+ assert [finding.location for finding in findings] == [str(SCHEME)]
+ assert BOUND_ACTION.value in findings[0].message
+
+ def test_an_action_every_scheme_states_passes(self) -> None:
+ assert check_shortcut_actions.unanswered_actions({SCHEME: {BOUND_ACTION.value}}, (BOUND_ACTION,)) == []
+
+ def test_each_scheme_is_held_to_the_whole_action_set(self) -> None:
+ """A scheme no platform loads here falls behind silently, so every shipped one is read."""
+ other = Path("keybindings/macos.yaml")
+ schemes = {SCHEME: {BOUND_ACTION.value}, other: set()}
+
+ findings = check_shortcut_actions.unanswered_actions(schemes, (BOUND_ACTION,))
+
+ assert [finding.location for finding in findings] == [str(other)]
+
+
+class TestUncalledActions:
+ def test_an_application_action_with_no_call_is_reported(self) -> None:
+ findings = check_shortcut_actions.uncalled_actions((BOUND_ACTION,), set())
+
+ assert _kinds(findings) == {check_shortcut_actions.UNCALLED_ACTION}
+ assert BOUND_ACTION.name in findings[0].message
+
+ def test_an_action_a_binding_answers_passes(self) -> None:
+ assert check_shortcut_actions.uncalled_actions((BOUND_ACTION,), {BOUND_ACTION.name}) == []
+
+ def test_an_action_a_family_answers_passes(self) -> None:
+ assert check_shortcut_actions.uncalled_actions((FAMILY_ACTION,), {FAMILY_ACTION.name}) == []
+
+ def test_a_panel_action_needs_no_call_of_its_own(self) -> None:
+ """A key scope acts on the press itself, so the shell's map answers for nothing there."""
+ assert PANEL_ACTION.category is not ShortcutCategory.APPLICATION
+ assert check_shortcut_actions.uncalled_actions((PANEL_ACTION,), set()) == []
+
+
+class TestTheEditorsVocabulary:
+ def test_a_rebindable_action_with_no_member_is_reported(self) -> None:
+ findings = check_shortcut_actions.unnamed_actions((BOUND_ACTION,), set())
+
+ assert _kinds(findings) == {check_shortcut_actions.UNNAMED_ACTION}
+
+ def test_a_dialog_action_is_named_nowhere(self) -> None:
+ """The dialog is operated by its own keys, so the editor leaves them as they are."""
+ assert check_shortcut_actions.unnamed_actions((DIALOG_ACTION,), set()) == []
+
+ def test_a_named_action_passes(self) -> None:
+ assert check_shortcut_actions.unnamed_actions((BOUND_ACTION,), {BOUND_ACTION.name}) == []
+
+
+class TestStaleEntries:
+ def test_a_scheme_naming_a_dropped_action_is_reported(self) -> None:
+ findings = check_shortcut_actions.stale_scheme_entries({SCHEME: {"RemovedLongAgo"}}, (BOUND_ACTION,))
+
+ assert _kinds(findings) == {check_shortcut_actions.STALE_ENTRY}
+ assert "RemovedLongAgo" in findings[0].message
+
+ def test_a_scheme_naming_only_live_actions_passes(self) -> None:
+ assert check_shortcut_actions.stale_scheme_entries({SCHEME: {BOUND_ACTION.value}}, (BOUND_ACTION,)) == []
diff --git a/tests/unit/scripts/checks/test_unused_tags.py b/tests/unit/scripts/checks/test_unused_tags.py
index 167e4a90a..1a6b033f5 100644
--- a/tests/unit/scripts/checks/test_unused_tags.py
+++ b/tests/unit/scripts/checks/test_unused_tags.py
@@ -17,12 +17,12 @@
TAG_GLOBAL_WINDOW_MAIN = TagName(Page.GLOBAL, Panel.IMPLICIT, Widget.WINDOW, "main")
SUF_BUTTON = "button"
SUF_BUTTON_COPY = compose_tag(SUF_BUTTON, "copy")
-PRE_RECONSTRUCTION_GENERATOR = "generator"
+PRE_RECONSTRUCTION_CHANNEL = "channel"
PANEL_WIDTH = 320
"""
PANEL_SOURCE: Final[str] = """
-from tags.general import PRE_RECONSTRUCTION_GENERATOR, SUF_BUTTON_COPY, TAG_GLOBAL_WINDOW_MAIN
+from tags.general import PRE_RECONSTRUCTION_CHANNEL, SUF_BUTTON_COPY, TAG_GLOBAL_WINDOW_MAIN
def build() -> None:
show(TAG_GLOBAL_WINDOW_MAIN, SUF_BUTTON_COPY)
@@ -52,7 +52,7 @@ def test_a_suffix_is_a_fragment(self) -> None:
assert {"SUF_BUTTON", "SUF_BUTTON_COPY"}.issubset(fragment_names(TAGS_SOURCE))
def test_a_prefix_is_a_fragment(self) -> None:
- assert "PRE_RECONSTRUCTION_GENERATOR" in fragment_names(TAGS_SOURCE)
+ assert "PRE_RECONSTRUCTION_CHANNEL" in fragment_names(TAGS_SOURCE)
def test_a_constant_of_another_kind_is_no_fragment(self) -> None:
assert "PANEL_WIDTH" not in fragment_names(TAGS_SOURCE)
@@ -69,12 +69,12 @@ def test_a_read_raises_the_count(self) -> None:
def test_an_import_alone_raises_no_count(self) -> None:
counts: Dict[str, int] = check_unused_tags.reference_counts([module(PANEL_MODULE, PANEL_SOURCE)])
- assert "PRE_RECONSTRUCTION_GENERATOR" not in counts
+ assert "PRE_RECONSTRUCTION_CHANNEL" not in counts
class TestUnreadFragments:
def test_a_fragment_nobody_reads_is_reported(self) -> None:
- assert unread(TAGS_SOURCE, PANEL_SOURCE) == ["PRE_RECONSTRUCTION_GENERATOR"]
+ assert unread(TAGS_SOURCE, PANEL_SOURCE) == ["PRE_RECONSTRUCTION_CHANNEL"]
def test_a_fragment_feeding_another_fragment_counts_as_read(self) -> None:
assert "SUF_BUTTON" not in unread(TAGS_SOURCE, PANEL_SOURCE)
@@ -83,7 +83,7 @@ def test_a_fragment_read_in_the_sources_counts_as_read(self) -> None:
assert "SUF_BUTTON_COPY" not in unread(TAGS_SOURCE, PANEL_SOURCE)
def test_a_tree_reading_every_fragment_reports_nothing(self) -> None:
- panel = PANEL_SOURCE.replace("SUF_BUTTON_COPY)", "SUF_BUTTON_COPY, PRE_RECONSTRUCTION_GENERATOR)")
+ panel = PANEL_SOURCE.replace("SUF_BUTTON_COPY)", "SUF_BUTTON_COPY, PRE_RECONSTRUCTION_CHANNEL)")
assert unread(TAGS_SOURCE, panel) == []
diff --git a/uv.lock b/uv.lock
index d28681761..1b3974a07 100644
--- a/uv.lock
+++ b/uv.lock
@@ -1355,6 +1355,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/80/6e/4b28b62ecb6aae56769c34a8ff1d661473ec1e9519e2d5f8b2c150086b26/pre_commit-4.6.0-py2.py3-none-any.whl", hash = "sha256:e2cf246f7299edcabcf15f9b0571fdce06058527f0a06535068a86d38089f29b", size = 226472, upload-time = "2026-04-21T20:31:40.092Z" },
]
+[[package]]
+name = "py65"
+version = "1.2.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/15/de/8ceb654608a24ae247ac44443b369f70183d9a39d94e682ddb3c9767b330/py65-1.2.tar.gz", hash = "sha256:e1cb213823fc6ea8aecb219939943144b09439e5c14b3406cb5ca825d5875b93", size = 83481, upload-time = "2024-04-12T20:10:19.351Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/f6/3d/7171f8401bb3cf058d233af314d3e314a348488bd22b2143b3591566a172/py65-1.2.0-py2.py3-none-any.whl", hash = "sha256:8e51f149647f2c2b44e5eb54350057771ccaef97f5f3d593fa3737e44c1ce4b7", size = 60518, upload-time = "2024-04-12T20:10:15.198Z" },
+]
+
[[package]]
name = "pyaudio"
version = "0.2.14"
@@ -1834,6 +1843,7 @@ dev = [
{ name = "mypy" },
{ name = "pillow" },
{ name = "pre-commit" },
+ { name = "py65" },
{ name = "pylint" },
{ name = "pylint-pydantic" },
{ name = "pytest" },
@@ -1878,6 +1888,7 @@ dev = [
{ name = "mypy", specifier = "==2.1.0" },
{ name = "pillow", specifier = ">=11,<13" },
{ name = "pre-commit", specifier = "==4.6.0" },
+ { name = "py65", specifier = "==1.2.0" },
{ name = "pylint", specifier = "==4.0.6" },
{ name = "pylint-pydantic", specifier = "==0.4.1" },
{ name = "pytest", specifier = "==9.1.1" },